Merge pull request #74 from ineszang/docs/coherence-40-data
docs(architecture): remet 40-data.md en accord avec le schema livre
This commit is contained in:
@@ -4,12 +4,12 @@ PostgreSQL 17 avec l'extension TimescaleDB. Le choix, ses alternatives et ses co
|
|||||||
dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md), qui fait foi. Ce document décrit le
|
dans l'[ADR 0001](../adr/0001-postgresql-timescaledb.md), qui fait foi. Ce document décrit le
|
||||||
système qui en découle.
|
système qui en découle.
|
||||||
|
|
||||||
## Avertissement
|
## Ce que couvre ce document
|
||||||
|
|
||||||
**Aucune table applicative n'existe à ce jour.** `Base.metadata` est vide, `app/models/` ne
|
**Dix tables applicatives existent** : quatre pour l'authentification, six pour les données
|
||||||
contient qu'un commentaire, l'unique révision Alembic ne crée aucune table, et aucune hypertable
|
d'énergie, dont l'hypertable `reading`. Les sections marquées `Fait` relèvent le code. Celles
|
||||||
n'a été déclarée. Tout ce qui suit sous le statut `Cible` est une proposition de structure, pas un
|
marquées `Cible` décrivent ce qui n'est pas écrit, au premier rang desquelles la chaîne
|
||||||
relevé du code. Le modèle sera arrêté au jalon J2.
|
d'ingestion, les agrégats continus, la compression et la rétention.
|
||||||
|
|
||||||
## Trois emplacements, trois rôles
|
## Trois emplacements, trois rôles
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ Statut : `Fait`.
|
|||||||
- `db/init/100-extensions.sql` crée l'extension `timescaledb`.
|
- `db/init/100-extensions.sql` crée l'extension `timescaledb`.
|
||||||
- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par
|
- `db/init/110-test-database.sql` crée `enervision_test`, dont le nom est attendu en dur par
|
||||||
`apps/backend/tests/conftest.py`.
|
`apps/backend/tests/conftest.py`.
|
||||||
- Quatre révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
|
- Cinq révisions Alembic. La première, `5353c0e4f094`, **ne crée aucune table** : elle
|
||||||
établit `alembic_version` et refuse de s'appliquer si l'extension manque :
|
établit `alembic_version` et refuse de s'appliquer si l'extension manque :
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@@ -48,16 +48,18 @@ Cette garde forme paire avec le 503 de `/api/v1/health/ready`. Un bootstrap saut
|
|||||||
au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés.
|
au démarrage de l'API : ces deux gardes le rendent visible tôt, des deux côtés.
|
||||||
|
|
||||||
Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`,
|
Les trois suivantes créent les tables de l'authentification, décrites plus bas : `app_user`,
|
||||||
puis `login_attempt` et `audit_log`, puis `refresh_token`.
|
puis `login_attempt` et `audit_log`, puis `refresh_token`. La cinquième, `e6d2026091501`, crée
|
||||||
|
les six tables de données décrites en fin de document et déclare l'hypertable `reading`.
|
||||||
|
|
||||||
## Cycle de vie d'une mesure
|
## Cycle de vie d'une mesure
|
||||||
|
|
||||||
Statut : `Cible`. Aucun de ces maillons n'existe.
|
Statut : `Cible`, sauf l'hypertable `reading` qui existe. Ni l'ingestion, ni les agrégats
|
||||||
|
continus, ni la compression, ni la rétention ne sont écrits.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
src["Source de mesures"] -.-> ing["Ingestion Airflow"]
|
src["Source de mesures"] -.-> ing["Ingestion Airflow"]
|
||||||
ing -.-> hy[("Hypertable mesure")]
|
ing -.-> hy[("Hypertable reading")]
|
||||||
hy -.-> agg[("Agrégat continu")]
|
hy -.-> agg[("Agrégat continu")]
|
||||||
hy -.-> comp["Compression"]
|
hy -.-> comp["Compression"]
|
||||||
hy -.-> ret["Rétention"]
|
hy -.-> ret["Rétention"]
|
||||||
@@ -133,67 +135,46 @@ donc **pas** une hypertable : une politique de rétention émettrait des `DELETE
|
|||||||
refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est
|
refuseraient. `login_attempt`, à l'inverse, est faite pour se purger, puisque son volume est
|
||||||
piloté par l'attaquant.
|
piloté par l'attaquant.
|
||||||
|
|
||||||
## Modèle métier
|
|
||||||
|
|
||||||
Statut : `Cible`. Les entités ci-dessous sont des **candidates**, à valider en J2. Elles
|
|
||||||
s'appuient sur les gabarits de [`apps/backend/TESTING.md`](../../apps/backend/TESTING.md), qui
|
|
||||||
évoquent déjà un modèle `Site`, un `SiteRepository` et un `ConsumptionService` exposant un
|
|
||||||
`total_kwh(site_id)`.
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
erDiagram
|
|
||||||
SITE ||--o{ POINT_DE_MESURE : porte
|
|
||||||
POINT_DE_MESURE ||--o{ MESURE : produit
|
|
||||||
|
|
||||||
SITE {
|
|
||||||
int id PK
|
|
||||||
string nom
|
|
||||||
}
|
|
||||||
POINT_DE_MESURE {
|
|
||||||
int id PK
|
|
||||||
int site_id FK
|
|
||||||
string libelle
|
|
||||||
string unite
|
|
||||||
}
|
|
||||||
MESURE {
|
|
||||||
timestamptz horodatage PK
|
|
||||||
int point_id PK
|
|
||||||
double valeur
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`MESURE` est la table destinée à devenir une hypertable, partitionnée sur `horodatage`. Sa clé
|
|
||||||
primaire doit inclure la colonne de temps : TimescaleDB l'exige, une clé sur le seul identifiant
|
|
||||||
de point serait refusée.
|
|
||||||
|
|
||||||
## Gabarit de révision créant une hypertable
|
## Gabarit de révision créant une hypertable
|
||||||
|
|
||||||
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision.
|
Conforme à la règle de l'ADR 0001 : table et hypertable dans la même révision. La révision
|
||||||
|
`e6d2026091501` en est l'exemple réel, réduit ici à l'essentiel.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
op.create_table(
|
op.create_table(
|
||||||
"mesure",
|
"reading",
|
||||||
sa.Column("horodatage", sa.DateTime(timezone=True), nullable=False),
|
sa.Column("reading_id", sa.BigInteger(), autoincrement=True, nullable=False),
|
||||||
sa.Column("point_id", sa.Integer(), sa.ForeignKey("point_de_mesure.id"), nullable=False),
|
sa.Column("site_id", sa.Text(), nullable=False),
|
||||||
sa.Column("valeur", sa.Float(), nullable=False),
|
sa.Column("timestamp", sa.DateTime(timezone=True), nullable=False),
|
||||||
sa.PrimaryKeyConstraint("horodatage", "point_id"),
|
sa.PrimaryKeyConstraint("reading_id", "timestamp"),
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"SELECT create_hypertable('reading', by_range('timestamp'), "
|
||||||
|
"create_default_indexes => FALSE)"
|
||||||
)
|
)
|
||||||
op.execute("SELECT create_hypertable('mesure', by_range('horodatage'))")
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.drop_table("mesure")
|
op.drop_table("reading")
|
||||||
```
|
```
|
||||||
|
|
||||||
|
La clé primaire inclut la colonne de temps parce que TimescaleDB l'exige : toute contrainte
|
||||||
|
unique d'une hypertable doit porter la colonne de partitionnement, et une clé sur le seul
|
||||||
|
`reading_id` serait refusée par `create_hypertable`.
|
||||||
|
|
||||||
|
`create_default_indexes => FALSE` écarte l'index que TimescaleDB pose d'office sur la seule
|
||||||
|
colonne de temps : les index déclarés dans la révision le couvrent déjà.
|
||||||
|
|
||||||
`drop_table` suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions.
|
`drop_table` suffit au retour arrière : supprimer la table supprime l'hypertable et ses partitions.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **Noms au singulier**, en minuscules, sans préfixe de table.
|
- **Noms au singulier**, en minuscules, sans préfixe de table : `app_user`, `reading`.
|
||||||
- **Toute colonne de temps en `timestamptz`.** Jamais de `timestamp` nu : une mesure sans fuseau
|
- **Toute colonne de temps en `timestamptz`.** Jamais de `timestamp` nu : une mesure sans fuseau
|
||||||
devient ininterprétable dès le premier changement d'heure.
|
devient ininterprétable dès le premier changement d'heure.
|
||||||
- **La colonne de partitionnement s'appelle `horodatage`** et entre dans la clé primaire.
|
- **La colonne de partitionnement entre dans la clé primaire.** Dans `reading` elle s'appelle
|
||||||
|
`timestamp` : c'est un nom de colonne, son type reste `timestamptz`.
|
||||||
- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic :
|
- **Les politiques de rétention et de compression** vont dans `db/migrations/`, pas dans Alembic :
|
||||||
elles ne découlent pas du schéma applicatif.
|
elles ne découlent pas du schéma applicatif.
|
||||||
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
|
- **Tout modèle doit être importé dans `app/models/__init__.py`**, sans quoi
|
||||||
@@ -201,13 +182,12 @@ def downgrade() -> None:
|
|||||||
|
|
||||||
## Questions ouvertes
|
## Questions ouvertes
|
||||||
|
|
||||||
Elles relèvent du jalon J2, « valider le périmètre retenu », et bloquent le modèle définitif.
|
Elles relèvent du jalon J2, « valider le périmètre retenu ». Le schéma est livré : ce qui suit
|
||||||
|
porte sur son exploitation, plus sur sa forme.
|
||||||
|
|
||||||
- **Quelles sources de mesures**, et selon quel protocole elles sont collectées.
|
|
||||||
- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure.
|
- **Quelle granularité** à l'ingestion : la seconde, la minute, le quart d'heure.
|
||||||
- **Quels agrégats continus**, et sur quelles fenêtres.
|
- **Quels agrégats continus**, et sur quelles fenêtres.
|
||||||
- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse.
|
- **Quelle profondeur de rétention** en données brutes, et à partir de quand on compresse.
|
||||||
- **Quelles unités** sont manipulées, et si une même table les mélange.
|
|
||||||
- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures.
|
- **Multi-tenant ou non** : un site appartient-il à un client, et faut-il cloisonner les lectures.
|
||||||
|
|
||||||
## Modélisation détaillée des données
|
## Modélisation détaillée des données
|
||||||
@@ -220,12 +200,11 @@ jusqu’aux recommandations proposées à l’utilisateur.
|
|||||||
### Schéma de données
|
### Schéma de données
|
||||||
|
|
||||||
Le diagramme ci-dessous présente les tables et leurs relations.
|
Le diagramme ci-dessous présente les tables et leurs relations.
|
||||||
Il décrit une structure de conception ; les migrations correspondantes
|
La révision `e6d2026091501` les crée.
|
||||||
restent à implémenter.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*Figure — Modélisation des données EnerVision.*
|
*Figure : Modélisation des données EnerVision.*
|
||||||
|
|
||||||
### Description des tables
|
### Description des tables
|
||||||
|
|
||||||
@@ -234,15 +213,15 @@ des données.
|
|||||||
|
|
||||||
| Table | Rôle | Origine des informations |
|
| Table | Rôle | Origine des informations |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `datasets` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
|
| `dataset` | Identifier les jeux historiques, retrouver leurs fichiers et conserver leurs métadonnées | Archive CSV/JSON et informations ajoutées lors de l’import |
|
||||||
| `sites` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
|
| `site` | Regrouper les informations des sites : identifiant, nom, type et caractéristiques disponibles | CSV et API Mock `/api/v1/sites` |
|
||||||
| `readings` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
|
| `reading` | Stocker les mesures, leur provenance, leur qualité et les éventuelles valeurs imputées | CSV et API Mock `/current` et `/readings` |
|
||||||
| `predictions` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
|
| `prediction` | Conserver les prévisions, leur période cible et la référence du modèle utilisé | Traitements ML d’EnerVision |
|
||||||
| `alerts` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
|
| `alert` | Enregistrer les alertes, leur type, leur gravité et leur message | API Mock `/alerts` et détections EnerVision |
|
||||||
| `recommendations` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
|
| `recommendation` | Proposer des actions et expliquer la règle qui les motive | Règles métier d’EnerVision |
|
||||||
|
|
||||||
Les anomalies historiques décrites dans les JSON sont conservées
|
Les anomalies historiques décrites dans les JSON sont conservées
|
||||||
dans `datasets.metadata`. Elles servent à l’analyse des données
|
dans `dataset.metadata`. Elles servent à l’analyse des données
|
||||||
et ne sont pas considérées comme des alertes actuelles.
|
et ne sont pas considérées comme des alertes actuelles.
|
||||||
|
|
||||||
### Relations entre les tables
|
### Relations entre les tables
|
||||||
|
|||||||
Reference in New Issue
Block a user