Commit af286816 authored by Lorys Hamadache's avatar Lorys Hamadache

Version Quasi Finale

parent 7911df21
# Projet NF26 - P19 - UTC
# Projet NF26 - P19 - UTC
Authors : Lorys Hamadache et Romain Creuzenet
### ToDos
1. Ajouter la nouvelle version du rapport
2. Ajouter la nouvelle version de la présentation
3. Clean Old Version presentation + rapport
4. Mettre la Nouvelle version du main.py
5. Clean les csv dans data et remplacer par le fichier Asos?
6. Rendre PUBLIC
7. Envoyer au prof
1. Verifier l'orthographe
2. Rendre le projet public
3. Confirmer les nouvelles versions rapports + code
4. Fichier Asos vs CSVs?
5. Envoyer au prof
## Comment utiliser le projet
• Pour installer le projet :
......
\documentclass{article}
\usepackage[utf8]{inputenc}
\usepackage{graphicx}
\usepackage{float}
\usepackage{hyperref}
\usepackage[a4paper]{geometry}
\geometry{hscale=0.85,vscale=0.90,centering}
\title{Projet NF26 –TD1 7/8}
\author{Lorys Hamadache — Romain Creuzenet}
\date{21 Juin 2019}
\begin{document}
\maketitle
\section{Introduction}
Le but de ce projet est de manipuler diverses données climatiques issues de plusieurs stations réparties sur le globe. C'est l'Espagne qui nous a été affecté et nous avons pris la liberté d’utiliser la période de temps de 2010 à 2013 plutôt que celle de 2001 à 2010, car cette dernière ne comportait pas suffisamment de données (2 stations seulement avec moins de 1000 entrées).\newline
L'enjeu principal est le stockage pertinent des données dans nos bases de données NoSQL Cassandra et d’exploiter ces résultats avec le langage Python pour répondre à plusieurs objectifs. Notre code se doit d'être adapté à une forte quantité de données.
\section{Stockage des données}
\emph{Le stockage des données est la partie la plus importante lors de la réalisation d'un tel outil d'analyse. C'est pourquoi on s'intéresse ici aux données et objectifs de notre outils afin de réaliser un stockage adapté. }
\subsection{Les données}
Nous nous intéressons ici au cas de l'Espagne. L'Espagne consiste en 62 stations réparties sur tout le territoire Espagnol continental en passant par les Îles Canaries et les Îles Baléares. Nos données contiennent 30 variables comme la température ou l'humidité. Les données sont téléchargeables en 1 seul fichier où chaque ligne représente une mesure avec sa station, les cordonnées de celle ci (lat,lon), la date de la mesure (Précision : Minute) et les mesures des différentes variables. Nous avons pour la période choisie (2011 - 2013) environ 1.5M d'entrées. On remarque aussi que nous avons des variables toujours nulles, ainsi que de nombreuses variables souvent nulles. \newline
Nous utilisons un stockage en colonne à l'aide de Cassandra mis à notre disposition sur 3 clusters. Notre système soit d'être toujours opérationnel même lors de l'absence des certaines parties et toujours nous retourner une valeur. C'est aussi un modèle qui nous permet de manipuler plus facilement toutes nos variables incomplètes et les colonnes presque nulles ne prennent pas trop d'espace.\newline
Nous utilisons seulement ces données la pour des questions de temps d'importation et de test. Il est plus simple de tester sur une quantité raisonnable de données pour ensuite porter le code en version finale avec toutes les données (2001 - 2019).
\subsection{Les Tables et nos objectifs}
\emph{Objectif 1 :Pour un point donné de l’espace, je veux pouvoir avoir un historique du passé, avec des courbes adaptés. Je vous pouvoir mettre en évidence la saisonnalité et les écarts à la saisonnalité.} \newline
Le premier objectif est, pour un point donné, autrement dit, pour l’une des stations espagnoles, d’obtenir un historique des données d’un attribut (par exemple l’humidité). On souhaite afficher et stocker des courbes expliquant l'évolution d'un attribut et sa saisonnalité. Comment stocker nos données afin de pouvoir les exploiter au mieux?\newline
Pour notre table, appelée TABLE\_SPACE, nous avons donc utilisé comme clef de partition la station, permettant ainsi d’obtenir l’ensemble de ses mesures et pouvoir faire l’historique de chacun. Pour différencier chacune de ces mesures, nous nous sommes servi de la date de celles-ci en clef de tri (année, mois, jour, minute, heure). \newline
Dans notre exploitation, nous choisissons une station puis un seul attribut (pour plus de clarté) et que, dans ce cas, la possibilité d’utiliser l’attribut comme clef de partition, la date comme clef de tri et y associer l’ensemble des mesures de chaque station est possible. Or, si suite à une évolution, nous voulons faire, en une fois, l’historique de tous les attributs d’une station cela n’est plus possible, d’où notre choix. De plus, nous stockons des informations non-essentielles comme la longitude ou la latitude. Cela est fait dans le cas d’une autre exploitation de la base.\newline
\emph{Objectif 2: À un instant donné je veux pouvoir obtenir une carte me représentant n’importe quel indicateur.} \newline
Le second objectif fut d’obtenir, à un instant donné, une carte affichant les mesures d’un indicateur. La table de l’objectif 1 n’était plus appropriée. Nous avons donc créé une seconde table (TABLE\_TIME) avec en clef de partition : la date (année, mois, jour, heur, minute), affin d’obtenir facilement l’ensemble des valeurs à un instant donnée. Comme clef de tri, nous aurions pu utiliser la station, mais nous avons préféré utiliser la longitude et la latitude car leur association correspond à une seule et unique station et ces données sont nécessaires pour ensuite pouvoir placer les mesures sur la carte.\newline
\emph{Objectif 3: À un instant donné je veux pouvoir obtenir une carte me représentant n’importe quel indicateur.} \newline
L’objectif 3 consiste à effectuer une clusterisation des différentes stations par rapport aux données météorologiques sur une période de temps demandée. Nous faisons une moyenne de chaque attribut de chaque station sur la période étudiée, en récoltant les données en streaming.\newline
Pour effectuer cet objectif, la table de l’objectif 2 a été reprise, car sa clef de partition est le temps. Une itération de requête est effectuée avec une date précise, en étant incrémentée de la plus petite unité de mesure : la minute. Nous aurions pu créer une nouvelle table partitionnée à l’heur ou au jour, mais avons choisi de garder la même table pour un souci de volume de donnée et en préconisant des périodes courtes. Un nouveau partitionnement aurait suivi les mêmes principes.\newline
\section{Notre outil et le traitement des données}
Dans cette partie nous allons voir en détail comment nous avons réaliser un outil permettant de répondre aux 3 objectifs et les traitements associés afin de gérer la quantité de données potentielle.
\subsection{Interface}
Nous avons choisi de réaliser une interface simple textuel en Python. Nous affichons du texte à l'utilisateur et celui ci peut saisir ses choix. Lorsque l'on lance notre programme, à partir de la commande {\tt >> python3 main.py}. Il est nécessaire de charger l'environnement Spark au préalable ({\tt source /pyspark.env}).
On se retrouve sur l'interface suivante où l'utilisateur peut faire un choix:
\begin{figure}[H]
\begin{center}
\includegraphics[width=0.6\textwidth]{figures/interface1.PNG}
\caption{\label{fig:i1} Interface Générale.}
\end{center}
\end{figure}
\subsection{Objectif 1}
Pour réaliser l'objectif 1 nous avons réaliser la fonction {\tt historic}. Comment gérons nous la quantité de donnée dont nous avons besoin? Dans un premier temps, la fonction est un prolongement de la partie interface. On demande à l'utilisateur de choisir la station sur laquelle il veut obtenir les courbes. Pour cela, on affiche toutes les stations ayant des données à l'aide d'une requête à la table TABLE\_SPACE de notre base Cassandra où nous sélectionnons les stations distincts. Il y a peu de stockage dans ce cas là donc pas de problème à ce niveau la. Ensuite on demande à l'utilisateur quel indicateur il souhaite visionner (température, humidité ...).\newline
On arrive à la partie intéressante. Comment gérer le nombre important de données de la station? Dans un premier temps on ne réalise la requête que sur ce que nous avons besoin, c'est à dire la date et la valeur de l'attribut.
Notre requête nous retourne un générateur. Mais celui ci peut contenir beaucoup trop de données. En effet nous avons quelques fois plusieurs mesures par heure, pendant plusieurs années. Il est donc impossible de stocker tout cela. Nous décidons d'utiliser Spark afin de paralléliser nos traitement et réaliser des mappings et reductions successifs. Nous mappons et réduisons nos données en ne gardant que la le maximum, le minimum et la moyenne de l'attribut sur la journée. Ces traitements sont réaliser à la suite (max puis min puis avg). Lorsque la quantité de données sera trop importante on pourra passer à une réduction par semaine ou par mois. Ces données, fortement réduite sont mainenant stocker dans une Time Series grâce à la bibliothèque {\tt pandas}. Nous affichons ensuite sur un même graphique le maximum, le minimum et la moyenne de l'indicateur au cours du temps sur cette station. On réalise une interpolation sur les données pour les jours manquants.\newline
\begin{figure}[H]
\begin{center}
\includegraphics[width=0.8\textwidth]{figures/graph_LELO_tmpf_byday.png}
\caption{\label{fig:g1}Exemple de tracé pour la station LELO et l'indicateur de température.}
\end{center}
\end{figure}
Avec la bibliothèque {\tt statsmodels}, on en profite pour réaliser un graphique d'autocorrelation ainsi qu'une décomposition de notre time series en une composante de tendance, une composante saisonnal et les résidus. Cela nous permet de répondre entièrement à la question en étudiant la saisonnalité du signal ainsi que les résidus hors tendance et saisons. On peut voir sur la décomposition une tendance à la baisse de la température et clairement un rythme saisonnier par année.
\begin{figure}[H]
\begin{center}
\includegraphics[width=0.4\textwidth]{figures/acf_LELO_tmpf.png}
\caption{\label{fig:g2}Exemple d'autocorellation pour la station LELO et l'indicateur de température.}
\end{center}
\end{figure}
\begin{figure}[H]
\begin{center}
\includegraphics[width=0.55\textwidth]{figures/decompose_LELO_tmpf.png}
\caption{\label{fig:g3}Exemple de décomposition pour la station LELO et l'indicateur de température.}
\end{center}
\end{figure}
\subsection{Objectif 2}
Dans un premier temps on demande une date et une heure et un attribut à l'utilisateur de la même façon que précedemment. Si la date n'existe pas, on lui affiche les heures disponibles avec des mesures dans le jour qu'il a demandé.
Pour cet objectif, nous n'avons pas de problème de données. En effet grâce à notre deuxième table (TABLE\_TIME) nous pouvons faire une requête, à un temps et un attribut donnés. La quantité de données est égale au nombre de stations *4 (Nom de la station, longitude et latitude de la station et valeur de l'attribut).Ensuite nous utilisons la bibliothèque {\tt mpl\_toolkits.basemap} pour afficher une carte de l'Espagne sur laquelle on trace les stations (des points) et auquel on annote à coté la valeur de l'attribut à cet emplacement. Cela nous permet de visualiser rapidement un indicateur à travers l'espace.
\begin{figure}[H]
\begin{center}
\includegraphics[width=0.65\textwidth]{figures/map_tmpf_du_2013_01_01_12_00.png}
\caption{\label{fig:m1}Température en Espagne le 1er Janvier 2013 à 12h00.}
\end{center}
\end{figure}
\subsection{Objectif 3}
En premier lieu, nous demandons à l'utilisateur la période sur laquelle il veut réaliser sa clusturisation. Après avoir obtenu le générateur de toutes les données sur cette période, on ne peut pas les stocker de cette façons pour raison évidente de coût mémoire, en particulier si la période est longue et que le nombre de données augmente. Pour cela, on calcule la moyenne par station de tous les attributs sur la période données, tout cela réalisé en "streaming" pour ne pas avoir de problème de mémoire.\newline
Le nombre de cluster souhaité est ensuite demandé à l’utilisateur. On réalise un algorithme des kmeans à la main. On pourrait paralléliser les calculs si nécéssaires. Pour clusteriser, dans un premier temps, nous générons les centroÏdes avec des valeurs aléatoires comprises entre le minimum et le maximum des moyennes recueillies pour chaque attribut. Nous clusterisons donc sur N dimensions, N étant le nombre d’attributs. La norme des vecteurs obtenus est comparée à celle de chaque station. De nouveaux centroïdes sont calculés par rapport à la moyenne des stations dont la norme de leur vecteur est la plus proche. Cette opération est effectuée jusqu’à une stabilisation des centroïdes. Si les centroïdes n’ont aucun station associée, les centroïdes sont renouvelées aléatoirement.
Une fois clusterisé, les stations sont affichées sur la carte comme dans l’objectif précédent. Leur appartenance à un cluster est visible par la couleur affichée. Sur l'exemple, on remarque qu'avec 3 clusters, quelques groupes logiques se dessinent. Nous avons en vert des stations côtières et en rouge des stations dans les terres.
\begin{figure}[H]
\begin{center}
\includegraphics[width=0.8\textwidth]{figures/3_clusters_du_2012_12_12_12_00_au_2012_12_21_12_00.png}
\caption{\label{fig:m2}Clusturisation de l'Espagne, avec 3 clusters, calculé sur la période du 2012-12-12 à 12h au 2012-12-21 à 12h.}
\end{center}
\end{figure}
\newpage
\section{Architecture du projet}
\emph{Le fichier {\tt parameters.py}:}\newline
Il permet de stoker la configuration du projet (pays concerné, période de temps étudiée…). Il possède également les informations utiles à tout le projet.\newline
\emph{Le fichier {\tt requierments.txt}:} \newline
Ce fichier permet d’obtenir toutes les librairies à installer pour faire fonctionner le projet.\newline
\emph{Le fichier {\tt download\_data.py}:}\newline
Ce fichier permet de télécharger les données du pays concerné sur la période temps étudiée depuis le site internet où elles sont stockées. Ces informations seront ensuite placées dans des fichiers csv par station dans le dossier {\tt out}. Le code est inspiré de celui trouvé sur le site internet gardant les données. Ce fichier n’est utilisé que très rarement, à une initialisation. Il suffit de l’exécuter pour appeler les bonnes fonctions.\newline
\emph{Le fichier {\tt create\_table.py}:}\newline
Ce fichier crée les différentes tables utilisées durant le projet et les remplie des informations contenues dans tous les fichiers du dossier {\tt data}. Ce fichier n’est utilisé que très rarement, à la première utilisation. Il suffit de l’exécuter pour appeler les bonnes fonctions. \newline
\emph{Le fichier {\tt main.py}:}\newline
Ce fichier gère l’exploitation des données. Il est utilisé fréquemment. Son exécution permet d’obtenir une interface dans le terminal pour choisir son objectif, les éléments nécessaires et obtenir les résultats.\newline
\emph{Le dossier {\tt out}:} \newline
Ce dossier stocke les graphiques issues de l’exploitation des données. Ils peuvent être consulté ultérieurement. \newline
\emph{Le dossier {\tt data}:}\newline
Ce dossier contient tous les fichiers contenant nos données qui devront être mises dans Cassandra. Ce dossier n’est pas censé être exploité manuellement par l’utilisateur.\newline
\section{Conclusion}
Ce projet est un projet intéressant pour se confronter à des données réelles. Cela nous permet de comprendre et de mettre en pratique nos connaissances sur les bases de données orientées colonnes. C'est en se posant des questions sur les données et en ne perdant pas de vues l'objectif final de pouvoir traiter énormément de données que l'on pense à réaliser un traitement de données en "streaming" et à réaliser du mapping et de la réduction. Pour aller plus loin que le "nec plus ultra" espagnol, on pourrait penser à paralléliser l'algorithme des kmeans ou utiliser la fonction existante dans Spark. De plus il serait intéressant d'étudier la performance en fonction du nombre de clusters et de répliquas utilisés. \newline
Vous trouverez le code complet à cette adresse : \href{https://gitlab.utc.fr/rcreuzen/nf26_projet/}{https://gitlab.utc.fr/rcreuzen/nf26\_projet/}\newline
\begin{center}
{\Large Merci pour votre lecture}
\end{center}
\end{document}
\ No newline at end of file
......@@ -8,6 +8,8 @@ import warnings
import re
import os
import random
#parallelize
from pyspark import SparkContext
# Stats
import statsmodels.graphics as stm_graphs
import pandas as pd
......@@ -16,7 +18,7 @@ import numpy as np
# Graph map
from mpl_toolkits.basemap import Basemap
from pandas.plotting import register_matplotlib_converters
from datetime import datetime, timedelta
from datetime import datetime
register_matplotlib_converters()
warnings.filterwarnings("ignore")
......@@ -27,7 +29,7 @@ def execute_query(query):
yield row
def ask_q(possibilities, text="Réponse : "):
def ask_q(possibilities, text=">>> "):
"""Demande une question"""
answer = None
while answer not in possibilities:
......@@ -35,7 +37,7 @@ def ask_q(possibilities, text="Réponse : "):
return answer
def ask_d(text="Réponse : "):
def ask_d(text=">>> "):
"""Demande une date"""
print("Entrez une date sous la forme YYYY-MM-DD HH:mm")
print("Comprise entre {} et {}".format(START.strftime('%Y-%m-%d'), END.strftime('%Y-%m-%d')))
......@@ -112,8 +114,7 @@ class Manager:
def run(self):
"""Chose objective"""
# Initialisation
for i in "123":
os.makedirs(os.path.join(DIR_OUT, "objectif_{}".format(i)), exist_ok=True)
os.makedirs(DIR_OUT, exist_ok=True)
# Chose objective
print("Choisissez ce que vous voulez faire")
......@@ -146,6 +147,8 @@ class Manager:
station = ask_q(stations)
attr = chose_attr()
# Base
ts = pd.Series()
query = "SELECT time, {} FROM {} WHERE station={}".format(attr, self.table, station.__repr__())
for row in execute_query(query):
......@@ -162,20 +165,79 @@ class Manager:
plt.plot(ts, label=attr)
plt.title("Donnees de {} pour la station : {}".format(attr, station))
plt.legend()
path = os.path.join(DIR_OUT, 'objectif_1', 'graph_{}_{}.png'.format(station, attr))
path = os.path.join(DIR_OUT, 'graph_{}_{}.png'.format(station, attr))
plt.savefig(path)
plt.show()
res = stm.tsa.seasonal_decompose(ts, freq=15, extrapolate_trend='freq')
#Initialisation SPARK
sc = SparkContext()
#INITIALISATION BY DAY
plt.figure(figsize=(25, 16))
axes = plt.subplot()
axes.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
plt.xticks(rotation=90)
date_rng = pd.date_range(start='2011-01-01', end='2013-12-31', freq='D')
# Maximum of the Day
ts_max = pd.Series(index = date_rng)
query = "SELECT time, {} FROM {} WHERE station={}".format(attr, self.table, station.__repr__())
gen_max = sc.parallelize(execute_query(query))
gen_max = gen_max.map(lambda line: ((getattr(line,"time")[0],getattr(line,"time")[1],getattr(line,"time")[2]), getattr(line,attr))).reduceByKey(lambda x,y: max(x,y)).collect()
for a,b in gen_max:
ts_max.loc[datetime(*list(a))] = b
ts_max = ts_max.sort_index()
ts_max = ts_max.interpolate()
plt.plot(ts_max , label="Maximum")
# Minimum of the Day
ts_min = pd.Series(index = date_rng)
query2 = "SELECT time, {} FROM {} WHERE station={}".format(attr, self.table, station.__repr__())
gen_min = sc.parallelize(execute_query(query2))
gen_min = gen_min.map(lambda line: ((getattr(line,"time")[0],getattr(line,"time")[1],getattr(line,"time")[2]), getattr(line,attr))).reduceByKey(lambda x,y: min(x,y)).collect()
for a,b in gen_min:
ts_min.loc[datetime(*list(a))] = b
ts_min = ts_min.sort_index()
ts_min = ts_min.interpolate()
plt.plot(ts_min , label="Minimum")
# Average of the Day
ts_avg = pd.Series(index = date_rng)
query3 = "SELECT time, {} FROM {} WHERE station={}".format(attr, self.table, station.__repr__())
gen_avg = sc.parallelize(execute_query(query2))
gen_avg = gen_avg.map(lambda line: ((getattr(line,"time")[0],getattr(line,"time")[1],getattr(line,"time")[2]), (getattr(line,attr),1))).reduceByKey(lambda x,y: (x[0] +y[0], x[1]+ y[1])).map(lambda x: (x[0],x[1][0]/x[1][1])).collect()
for a,b in gen_avg:
ts_avg.loc[datetime(*list(a))] = b
ts_avg = ts_avg.sort_index()
ts_avg = ts_avg.interpolate()
plt.plot(ts_avg , label="Moyenne")
# Global Plotting
plt.title("Donnees de {} pour la station : {}".format(attr, station))
plt.legend()
path = os.path.join(DIR_OUT, 'graph_{}_{}_byday.png'.format(station, attr))
plt.savefig(path)
plt.show()
res = stm.tsa.seasonal_decompose(ts_avg.dropna(), freq=365 , extrapolate_trend='freq')
res.plot()
path = os.path.join(DIR_OUT, 'objectif_1', 'decompose_{}_{}.png'.format(station, attr))
path = os.path.join(DIR_OUT, 'decompose_{}_{}.png'.format(station, attr))
plt.savefig(path)
plt.show()
stm_graphs.tsaplots.plot_acf(ts, lags=30)
path = os.path.join(DIR_OUT, 'objectif_1', 'acf_{}_{}.png'.format(station, attr))
stm_graphs.tsaplots.plot_acf(ts_avg.dropna(), lags=365)
path = os.path.join(DIR_OUT, 'acf_{}_{}.png'.format(station, attr))
plt.savefig(path)
plt.show()
def map(self):
self.table = "TABLE_TIME"
......@@ -218,7 +280,7 @@ class Manager:
plt.title(title)
for elt in ' :-':
title = title.replace(elt, '_')
path = os.path.join(DIR_OUT, 'objectif_2', title.lower() + '.png')
path = os.path.join(DIR_OUT, title.lower() + '.png')
plt.savefig(path)
plt.show()
......@@ -259,31 +321,25 @@ class Manager:
print("Entrez le nombre de cluster voulus")
nb_cluster = ask_int()
# Calc of mean
query = "SELECT station, lon, lat, {attr} FROM {table} WHERE time >= {begin} AND time <= {end} " \
"ALLOW FILTERING".format(
attr=", ".join(ATTRIBUTS.keys()),
table=self.table,
begin=date_begin,
end=date_end
)
stations = {} # station: {'nb': 3, 'attr1': 5, 'attr2': 7, ..., 'lon': 3.27, 'lat': 12}
datetime_begin = datetime(*list(date_begin)) # Convert datetime
datetime_end = datetime(*list(date_end)) # Convert datetime
while datetime_begin <= datetime_end:
print("Données récupérée pour {}".format(datetime_begin.strftime("%Y-%m-%d %H:%M")), end="\r")
# Calc of mean
query = "SELECT station, lon, lat, {attr} FROM {table} WHERE time = {date}".format(
attr=", ".join(ATTRIBUTS.keys()),
table=self.table,
date=(datetime_begin.year, datetime_begin.month, datetime_begin.day, datetime_begin.hour,
datetime_begin.minute)
)
for row in execute_query(query):
if None in [row.station, row.lon, row.lat] + [getattr(row, attr) for attr in ATTRIBUTS.keys()]:
continue
if row.station not in stations:
stations[row.station] = {'nb': 0, 'lon': row.lon, 'lat': row.lat,
**{key: 0 for key in ATTRIBUTS.keys()}}
for row in execute_query(query):
if None in [row.station, row.lon, row.lat] + [getattr(row, attr) for attr in ATTRIBUTS.keys()]:
continue
if row.station in stations:
for attr in ATTRIBUTS.keys():
stations[row.station][attr] += getattr(row, attr)
stations[row.station]['nb'] += 1
datetime_begin += timedelta(minutes=1)
else:
stations[row.station] = {'nb': 1, 'lon': row.lon, 'lat': row.lat,
**{key: 0 for key in ATTRIBUTS.keys()}}
for value in stations.values():
for attr in ATTRIBUTS.keys():
value[attr] = value[attr] / value['nb']
......@@ -295,8 +351,6 @@ class Manager:
for _ in range(nb_cluster)
]
print()
print("Clusterisation...")
while old_centroids != new_centroids:
old_centroids = new_centroids
data = [
......@@ -370,7 +424,7 @@ class Manager:
plt.title(title)
for elt in ' :-':
title = title.replace(elt, '_')
path = os.path.join(DIR_OUT, 'objectif_3', title.lower() + '.png')
path = os.path.join(DIR_OUT, title.lower() + '.png')
plt.savefig(path)
plt.show()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment