Ce document explique en des termes simples les opérations effectuées sur les données recueillies par les capteurs de CitizenWatt afin d'être affichées.
Le capteur envoie une mesure de puissance en watts (W) toutes les $T_0 = 8s$.
Cette période n'est pas exacte, il faut donc prendre en compte de petites variations autour et ne pas reposer sur un délais parfait.
D'autre part, la communication entre le capteur et la base effectuant les calculs étant sans fil, il n'est pas impossible de perdre quelques mesures ponctuellement.
Enfin, le capteur fonctionne sur piles et il peut donc y avoir des plages longues sans données, entre la fin des piles et leur remplacement.
L'affichage se fait en kilowatts-heure (kWh) ou en euros (€) et il faut donc convertir ces données. La conversion en euros nécessite la prise en compte de la distinction entre tarif de jour et tarif de nuit.
D'autre part, l'instabilité des mesures nécessite à la fois de masquer les petits manques de mesure, mais également de signaler les manques à long terme.
Note: Cette conversion s'effectue dans le code de la base dans la fonction libcitizenwatt.tools.energy
.
L'idée de base est de faire une simple intégration :
$$E (\text{en kWh}) = \int P (\text{en kW}) dt (\text{en h})$$Notez déjà les unités : pour avoir des kilowatts-heure (kWh), il faut des temps en heures (h) et des mesures en kilowatts (kW).
$$t (\text{en h}) = t (\text{en s}) / 3600$$$$P (\text{en kW}) = P (\text{en W}) / 1000$$Pour ce qui est de trouver $P$, tout dépend de la méthode d'intégration. Je ne vais pas entrer dans les détails, il existe un certain nombre de méthodes dont le degré de difficulté mathématique est très variable puisque nous utilisons pour le moment la plus simple.
On connaît la valeur de $P$ tous les $T_0$, lorsque le capteur nous donne une mesure, mais pour intégrer, il faut connaître toutes les données. Le plus simple est alors de considérer que la puissance reste à peu près la même entre les deux mesures :
figure1()
On peut également envisager d'utiliser la méthode des trapèzes, qui n'est pas beaucoup plus compliquée et plus précise, mais dont le principal inconvénient est qu'elles nécessite de connaître la valeur suivante. On conserve donc la méthode précédente (en escaliers) pour la dernière mesure :
figure2()
Pour un tarif donné, la conversion en euros pour un mois complet est affine en l'énergie $E$ consommée :
$$F_{\text{mois}} (\text{en €}) = \alpha \cdot E_{\text{mois}} (\text{en kWh}) + \beta$$Les constantes de tarif $\alpha$ et $\beta$ sont données par le type d'abonnement de l'utilisateur.
Afin de rapporter cette relation à une période $T$ différente du mois, la constante $\beta$ est corrigée linéairement :
$$\beta_T = \beta_{\text{mois}} \cdot \frac{T}{\text{1 mois}}$$On obtient donc, de façon plus générale :
$$F_T (\text{en €}) = \alpha \cdot E_T (\text{en kWh}) + \beta \cdot \frac T {\text{1 mois}}$$On assure ainsi l'application d'une relation de Chasles aux prix affichés :
$$F_{AB} = F_A + F_B$$Une donnée peut manquer dans deux situations :
Un problème de communication temporaire fait perdre une ou plusieurs mesures. Nous avons fait le choix de ne corriger que s'il manque une valeur, car bien que l'on ne veuille pas que le graphe de l'utilisateur soit plein de données manquantes, on ne veut pas non plus lui donner l'illusion que des données sont présentes si ce n'est pas le cas.
Les incertitudes sur la période de 8 s font que la tranche d'exactement 8 s qu'une requête demande ne contient aucune données. C'est moins improbable qu'on ne peut le penser au premier abord. Par exemple, si des données sont arrivées à $t=15.98s$ puis à $t=24.02$ et que l'on demande la consomation entre $t=16$ et $t=24$ pour dessiner l'une des barres du graphe, aucune donnée ne sera disponible. (voir Figure 3)
Sur la durée de vie d'un capteur, l'écart entre son horloge et celle de la base est bien réelle et on ne peut donc pas assurer qu'il suffit pour éviter le second point de prendre des valeurs entre $8n-4$ secondes et $8n+4$ secondes et supposer que toutes les mesures sont autour de $8n$ secondes.
figure3()
Lorsqu'une mesure manque en milieu de bloc sur lequel on intègre les données, elle est remplacée par la moyenne des deux valeurs voisines. Cela nécessite donc qu'il y ait une valeur avant et après.
Note : Pour le cas de la méthode des trapèze, ça ne change pas la valeur de l'intégrale par rapport au cas où l'on omet de reconstruire la valeur manquante.
figure4()
figure5()
Dans le cas de mesure manquante en limite, et en fait plus généralement au niveau des bornes d'intégration, une mesure au niveau de la borne est reconstruite :
figure6()
Cette partie doit être exécutée avant le reste du document afin de définir les différentes figures.
%matplotlib inline
from matplotlib.pyplot import figure
from matplotlib.patches import Polygon
import numpy as np
# Prenons quelques mesures d'exemple
times = [0.00, 8.01, 16.02, 23.97, 32.00, 39.99]
measures = [4.52, 3.28, 2.87, 4.02, 3.93, 2.69 ]
def figure1():
plot = figure().add_axes([0, 0, 1, 1])
plot.set_ylim([0, 5])
plot.spines['right'].set_color('none')
plot.spines['top'].set_color('none')
plot.yaxis.tick_left()
plot.xaxis.tick_bottom()
plot.grid(color='black', alpha=0.5, linestyle='dashed', linewidth=0.5, axis='y')
plot.set_xlabel('Temps (s)')
plot.set_ylabel('Puissance (W)')
plot.set_xticks([0, 8, 16, 24, 32, 40, 48])
plot.scatter(times, measures, marker='+', color='orange', alpha=0.8)
plot.bar(times, measures, width=8, linewidth=0, color='brown', alpha=0.1, label='Données supposées')
plot.bar(times, measures, width=0, edgecolor='orange', color='orange', alpha=0.8, label='Données connues')
plot.legend(loc=1)
return
def figure2():
plot = figure().add_axes([0, 0, 1, 1])
plot.set_ylim([0, 5])
plot.spines['right'].set_color('none')
plot.spines['top'].set_color('none')
plot.yaxis.tick_left()
plot.xaxis.tick_bottom()
plot.grid(color='black', alpha=0.5, linestyle='dashed', linewidth=0.5, axis='y')
plot.set_xlabel('Temps (s)')
plot.set_ylabel('Puissance (W)')
plot.set_xticks([0, 8, 16, 24, 32, 40, 48])
plot.scatter(times, measures, marker='+', color='orange', alpha=0.8)
verts = [(times[0], 0)] + list(zip(times, measures)) + [(times[-1], 0)]
poly = Polygon(verts, facecolor='brown', alpha=0.1, linewidth=0, label='Données supposées')
plot.add_patch(poly)
plot.bar(times[-1], measures[-1], width=8, linewidth=0, color='brown', alpha=0.1)
plot.bar(times, measures, width=0, edgecolor='orange', color='orange', alpha=0.8, label='Données connues')
plot.legend(loc=1)
return
def figure3():
times = [15.9, 24.1]
measures = [3.54, 4.28]
plot = figure().add_axes([0, 0, 1, 1])
plot.set_ylim([0, 5])
plot.spines['right'].set_color('none')
plot.spines['top'].set_color('none')
plot.yaxis.tick_left()
plot.xaxis.tick_bottom()
plot.grid(color='black', alpha=0.5, linestyle='dashed', linewidth=0.5, axis='y')
plot.set_xlabel('Temps (s)')
plot.set_ylabel('Puissance (W)')
plot.set_xticks(times)
plot.scatter(times, measures, marker='+', color='orange', alpha=0.8)
plot.bar([16], [1], width=8, linewidth=0, color='brown', alpha=0.1)
plot.bar(times, measures, width=0, edgecolor='orange', color='orange', alpha=0.8)
return
def figure4():
global times, measures
times2 = times.copy()
measures2 = measures.copy()
del times2[2]
del measures2[2]
plot = figure().add_axes([0, 0, 1, 1])
plot.set_ylim([0, 5])
plot.spines['right'].set_color('none')
plot.spines['top'].set_color('none')
plot.yaxis.tick_left()
plot.xaxis.tick_bottom()
plot.grid(color='black', alpha=0.5, linestyle='dashed', linewidth=0.5, axis='y')
plot.set_xlabel('Temps (s)')
plot.set_ylabel('Puissance (W)')
plot.set_xticks([0, 8, 16, 24, 32, 40, 48])
plot.bar(times2, measures2, width=8, linewidth=0, color='brown', alpha=0.1)
plot.scatter(times2, measures2, marker='+', color='orange', alpha=0.8)
plot.bar(times2, measures2, width=0, edgecolor='orange', color='orange', alpha=0.8, label='Données connues')
plot.scatter((times2[1] + times2[2]) / 2, (measures2[1] + measures2[2]) / 2, marker='+', color='red', alpha=0.5)
plot.bar((times2[1] + times2[2]) / 2, (measures2[1] + measures2[2]) / 2, width=0, edgecolor='red', color='red', alpha=0.5, label='Données reconstruites')
plot.bar((times2[1] + times2[2]) / 2, (measures2[1] + measures2[2]) / 2, width=8, linewidth=0, color='red', alpha=0.2)
plot.legend(loc=1)
return
def figure5():
global times, measures
times2 = times.copy()
measures2 = measures.copy()
del times2[2]
del measures2[2]
plot = figure().add_axes([0, 0, 1, 1])
plot.set_ylim([0, 5])
plot.spines['right'].set_color('none')
plot.spines['top'].set_color('none')
plot.yaxis.tick_left()
plot.xaxis.tick_bottom()
plot.grid(color='black', alpha=0.5, linestyle='dashed', linewidth=0.5, axis='y')
plot.set_xlabel('Temps (s)')
plot.set_ylabel('Puissance (W)')
plot.set_xticks([0, 8, 16, 24, 32, 40, 48])
verts = [(times2[0], 0)] + list(zip(times2, measures2)) + [(times2[-1], 0)]
poly = Polygon(verts, facecolor='brown', alpha=0.1, linewidth=0)
plot.add_patch(poly)
plot.bar(times2[-1], measures2[-1], width=8, linewidth=0, color='brown', alpha=0.1)
plot.scatter(times2, measures2, marker='+', color='orange', alpha=0.8)
plot.bar(times2, measures2, width=0, edgecolor='orange', color='orange', alpha=0.8, label='Données connues')
plot.scatter((times2[1] + times2[2]) / 2, (measures2[1] + measures2[2]) / 2, marker='+', color='red', alpha=0.5)
plot.bar((times2[1] + times2[2]) / 2, (measures2[1] + measures2[2]) / 2, width=0, edgecolor='red', color='red', alpha=0.5, label='Données reconstruites')
plot.legend(loc=1)
return
def figure6():
times = np.arange(len(measures)) * 8
a, b = 10, 30 # Bornes d'intégration
plot = figure().add_axes([0, 0, 1, 1])
plot.set_ylim([0, 5])
plot.spines['right'].set_color('none')
plot.spines['top'].set_color('none')
plot.yaxis.tick_left()
plot.xaxis.tick_bottom()
plot.grid(color='black', alpha=0.5, linestyle='dashed', linewidth=0.5, axis='y')
plot.set_xlabel('Temps (s)')
plot.set_ylabel('Puissance (W)')
plot.set_xticks(times)
t_a = (a - times[1]) / (times[2] - times[1])
m_a = measures[1] * (1 - t_a) + measures[2] * t_a
t_b = (b - times[3]) / (times[4] - times[3])
m_b = measures[3] * (1 - t_b) + measures[4] * t_b
verts = [(a, 0), (a, m_a)] + list(zip(times[2:4], measures[2:4])) + [(b, m_b), (b, 0)]
poly = Polygon(verts, facecolor='brown', alpha=0.1, linewidth=0)
plot.add_patch(poly)
plot.bar([a], [0.1], width=b - a, linewidth=0, color='blue', alpha=0.5, label='Intervale d\'intégration')
plot.scatter(times, measures, marker='+', color='orange', alpha=0.8)
plot.bar(times, measures, width=0, edgecolor='orange', color='orange', alpha=0.8)
plot.scatter([a, b], [m_a, m_b], marker='+', color='red', alpha=0.5)
plot.bar([a, b], [m_a, m_b], width=0, edgecolor='red', color='red', alpha=0.5, label='Données reconstruites')
plot.legend(loc=1)
return