Backend (Fastify + Prisma)¶
Décomposition C4-C3 du container API. Monolithe Fastify hébergeant à la fois les endpoints REST et le cron d'ingestion LINDAS (ADR-003).
Vue C4 niveau 3 backend (exportée depuis Structurizr) :
5.B.1 Organisation du code¶
apps/api/src/
├── routes/ # Définition Fastify (route + schema Zod + handler)
│ ├── health.ts # GET /api/v1/health
│ ├── status.ts # GET /api/v1/status
│ ├── ai.ts # GET /api/v1/ai/status (couche IA D, ADR-012)
│ ├── alerts.ts # GET /api/v1/alerts (couche IA B, ADR-012)
│ ├── ask.ts # POST /api/v1/ask — chat sur données structurées (couche IA C, ADR-012)
│ ├── energy.ts # GET /api/v1/energy/{positioning,trajectory,breakdown} (extension énergie, ADR-013)
│ └── stations.ts # GET /stations, /stations/:id/measurements, /stations/:id/narrative
├── services/ # Orchestration métier (use cases)
│ ├── stations-service.ts
│ ├── alerts-service.ts # réconciliation épisodique Alert + lecture /alerts (couche IA B, ADR-012)
│ ├── narrative-service.ts # mesures → features → narration (couche IA, ADR-012)
│ └── energy-service.ts # lecture des trois écrans ElCom, cache par import (ADR-013)
├── energy/ # Extension énergie — tarifs ElCom (ADR-013)
│ ├── elcom-queries.ts # requêtes SPARQL des cubes ElCom (littéraux xsd:gYear)
│ ├── elcom-parser.ts # mappers de lignes purs, prix en chaînes décimales, réconciliation
│ ├── elcom-import.ts # passe d'import idempotente → IngestionRun LINDAS_ELCOM
│ ├── import-elcom-cli.ts # commande manuelle (jamais cron, jamais au boot)
│ ├── elcom-categories.ts # 15 catégories + consommation (schema:size ElCom)
│ └── positioning.ts # statistiques pures : quantiles, 4 bases, verdict
├── ai/ # Couche IA (ADR-012 — A narration, D observabilité, C chat)
│ ├── narration-features.ts # extraction de features pure et groundée (A2)
│ ├── llm-client.ts # interface LlmClient isolée + impl Mistral (A3) + completeWithTools (C2)
│ ├── narration-prompt.ts # prompt déterministe + PROMPT_VERSION (A3)
│ ├── narration-service.ts # cache Insight idempotent + générateur (A3)
│ ├── observing-llm-client.ts # décorateur → 1 LlmCallRun/appel, coût/latence (D3)
│ ├── llm-pricing.ts # computeCostUsd — tarifs publics, pur (D2)
│ └── chat/ # Chat sur données structurées (extension C)
│ ├── query-port.ts # QueryPort = les 4 fonctions whitelistées (frontière hexagonale, C1)
│ ├── prisma-query-adapter.ts # impl Prisma déléguant aux services existants (C1/D2)
│ ├── tools.ts # specs des 4 tools + dispatcher vers le QueryPort (C3a)
│ ├── chat-prompt.ts # prompt grounding-strict (C3a)
│ ├── chat-service.ts # orchestrateur — boucle tool bornée 2 rounds (C3b)
│ └── rate-guard.ts # garde unifié maison : cost-cap + rate-limit IP (C4)
├── anomaly/ # Détection d'anomalie STATISTIQUE (ADR-012, extension B — PAS de LLM)
│ ├── anomaly-detection.ts # z-score déseasonnalisé par heure, pur + testable (B1/B1-bis)
│ └── anomaly-scan.ts # scan isolé post-ingestion → réconcilie les épisodes (B2)
├── domain/ # Entités métier pures (sans dépendance framework)
├── plugins/ # Plugins Fastify transverses
│ ├── cors.ts # CORS allowlist via CORS_ORIGINS
│ ├── prisma.ts # Singleton PrismaClient
│ ├── llm.ts # Singleton LlmClient (Mistral ; LiteLLM en D)
│ ├── ingestion.ts # Cron 10 min + orchestration ingestion
│ └── ...
├── ingestion/
│ ├── sparql-client.ts # client SPARQL générique : User-Agent, timeout, pagination (ADR-013)
│ └── lindas/ # Parser SPARQL + upsert idempotent + archive gzip
│ ├── fetch.ts
│ ├── parser.ts
│ ├── persist.ts
│ └── archive.ts
├── schemas/ # Zod (request/response) — certains ré-exportés depuis packages/shared
├── utils/
└── index.ts # bootstrap Fastify + register plugins + listen
Les couches domain/ + services/ sont fines aujourd'hui — le code principal vit dans routes/ (handlers) et plugins/ingestion.ts. Une séparation plus stricte se justifiera quand la surface métier grandira.
5.B.2 Endpoints REST¶
Tous versionnés sous /api/v1, base URL conventionnelle. Onze endpoints :
GET /api/v1/health— liveness probe minimaliste. Non authentifié.{ status, timestamp, database }. Retourne 503 si Postgres est inaccessible. Consommé par Coolify/Traefik pour déterminer si le container est prêt à recevoir du trafic.GET /api/v1/status— observabilité élargie. Non authentifié. Exposeingestion.lastRun(dernierIngestionRun, dontmeasurementsCreatedCountetmeasurementsUnchangedCount— cf. D0, compteurs honnêtes créés vs ré-vus),ingestion.lastSuccessAt,ingestion.healthyThresholdMinutes,ingestion.today.{runsCount, measurementsCreatedSum, successRate}. Consommé par le badgeMStatusBadgedansOHeroSection(polling 60 s).GET /api/v1/ai/status— observabilité de la couche IA (ADR-012, D). Non authentifié. Agrège lesLlmCallRundu jour :ai.{callsToday, errorRate, costUsdToday, avgLatencyMs, lastCallAt}. DB-down → 503. Consommé par un secondMStatusBadgeglobal dansOHeroSection(opérationnel/dégradé/indisponible).GET /api/v1/alerts— liste read-only des épisodes d'anomalie statistique (ADR-012, B). Non authentifié. Query params (Zod) :status(open|closed|all, défautopen),stationId?,type?,limit?(défaut 50, max 200). Retourne{ data: AlertDTO[] }triéopenedAt DESC; chaqueAlertDTOportelevel,parameter,triggerValue,thresholdValue,openedAt/closedAtet un sacmetadataexposant les stats groundées (mean/std/z/hourBucket/bucketSampleSize/fenêtre). Consommé parOAlertsPanel(vue réseau) ; le compteur d'épisodes ouverts par station alimente aussiactiveAlertsCountsur/stations(halo carte).GET /api/v1/stations— liste des stations actives avec leurslatestMeasurementspar paramètre (DISCHARGE,WATER_LEVEL). Champs clés :ofevCode,dataSource(LIVE|RESEARCH|SEED),sourcingStatus(CONFIRMED|ILLUSTRATIVE). Query params :catchmentId?,isActive?.GET /api/v1/stations/:id/measurements— séries temporelles d'une station sur une fenêtre[from, to]. Query params obligatoires :from,to(ISO 8601). Optionnel :parameter=DISCHARGE. Retourne{ data: { stationId, from, to, aggregate: 'raw', series: MeasurementSeries[] } }.GET /api/v1/stations/:id/narrative— résumé LLM groundé d'un paramètre sur une fenêtre (couche IA, ADR-012). Query params :parameter(requis),from,to(ISO 8601),lang?(défautfr). Validation Zod ; 404 station inconnue, 400 requête invalide. La couche IA ne renvoie jamais d'erreur HTTP : réponse 200 avec un état métierstate: generated | cached | unavailable(+reason: insufficient_data | llm_error | config_error),text(null si indisponible) etgrounding(trend, deltaAbs, deltaPct, status, completeness) exposé pour transparence. DTOStationNarrativeDTOdanspackages/shared.-
POST /api/v1/ask— chat sur données structurées par function-calling (couche IA C, ADR-012). Non authentifié, public. Body (Zod) :{ question, language? }(défautfr). C'est la surface la plus sensible de la couche IA — les gardes C1→C4 y convergent, dans l'ordre : (1) rate-guard par IP + plafond coût (C4) court-circuite en 429 avant tout parsing ou appel LLM ; (2) validation Zod → 400 ; (3) chat groundé sur leQueryPort→ 200{ answer, used: ToolUse[], language, generatedAt }(used= trace honnête des tools appelés) ; (4) unLlmErrordégrade gracieusement en 503AI_UNAVAILABLE, jamais un 500 brut. DTOAskResponseDTOdanspackages/shared. Détail du flux en §5.B.8 et §6.4 runtime. -
GET /api/v1/energy/positioning— écran Positionnement de l'extension énergie (ADR-013 §4). Non authentifié, sans paramètre. Retourne{ data: EnergyPositioningDTO }pour le dernier millésime importé :period,operator(elcomId,name),operatorsTotal,singleMunicipalityOperators,operatorMunicipalities, et pour chacune des 15 catégoriesoperatorValue,bases[](quatre bases :allOperators,operatorsMin2,operatorsMin5,communeWeighted, chacune avecn,min/q1/median/q3/max,operatorGapPct,operatorPercentile,operatorSide),verdict(above|below|nonDiscriminating|missing),amplitudePtsetelcomSwissMedian(repère publié, distinct des bases). Prix en ct./kWh ;null= non publié, jamais 0. GET /api/v1/energy/trajectory— écran Trajectoire. Retourne{ data: EnergyTrajectoryDTO }: par catégorie, un point par millésime importé avecoperatorValue(médiane des tarifs communaux de l'opérateur),operatorMunicipalities,elcomSwissMedianetelcomValaisMedian(médianes publiées par l'ElCom).GET /api/v1/energy/breakdown— écran Décomposition. Retourne{ data: EnergyBreakdownDTO }pour le dernier millésime : par catégorie,consumptionKwh,operatorTotalet cinq composantes (gridUsage,energy,charge,aidFee,meteringRate) avec la valeur de l'opérateur, la médiane des gestionnaires et son effectifn.
Les trois routes énergie répondent 404 ENERGY_DATA_NOT_IMPORTED tant qu'aucun import ElCom n'a réussi. DTO dans packages/shared/src/types/energy.ts. Détail en §5.B.9.
Chaque endpoint est typé via un schéma Zod (schemas/) appliqué à la fois en validation request et en shape response. Les DTO sont définis dans packages/shared/src/types/ — même source de vérité côté front et back.
Épopées PRD hors scope v1 (admin thresholds, alerts CRUD/notifications, stations/:id détail complet, /auth/login, /compare) — tracées dans §3.3 périmètre et dans le PRD. La lecture d'anomalies (GET /alerts, détection statistique) est en revanche livrée via la couche IA additive (ADR-012, B) ; seules l'écriture/admin et les notifications restent exclues.
5.B.3 Plugin d'ingestion LINDAS¶
apps/api/src/plugins/ingestion.ts orchestre le cycle :
- Scheduling —
node-cronavec expressionINGESTION_SCHEDULE(défaut*/10 * * * *). Démarrage auonReadyhook Fastify, cleanup suronClose. - Fetch —
undiciHTTP POST vershttps://lindas.admin.ch/queryavec la requête SPARQL cible (apps/api/src/ingestion/lindas/fetch.ts). Timeout 10 s, retry 3× backoff exponentiel. - Parse — validation Zod des bindings
SPARQL results JSONavant persistance (parser.ts). Un binding malformé skip sans faire échouer le run entier. - Persist — upsert idempotent sur clé composite
{stationId, parameter, recordedAt}via Prisma (persist.ts). Rejouer deux fois le même tick n'insère jamais de doublon. - Archive —
gzipdu payload SPARQL brut sur disque dans/app/var/lindas-archive/YYYY-MM-DD/<timestamp>-<hash>.json.gz(named volume Docker). Rotation 30 jours. Échec d'archive non-fatal (loguéwarn, cf. post-mortem 2026-04-22 EACCES). - Trace — chaque tick persiste un
IngestionRun(source: 'LINDAS_HYDRO',status: SUCCESS | FAILURE | PARTIAL, compteurs, durée, hash payload). Lu par/status.
Environnement contrôlable via :
INGESTION_ENABLED(défauttrueen prod, souventfalseen tests pour désactiver le cron)INGESTION_SCHEDULE(cron expression)INGESTION_HEALTHY_THRESHOLD_MINUTES(défaut 30, piloté par/status.ingestion.healthyThresholdMinutes)
5.B.4 Plugins transverses¶
plugins/cors.ts— CORS allowlist viaCORS_ORIGINS(env, séparé par virgules). Aucune étoile en prod.plugins/prisma.ts— singletonPrismaClientattaché au lifecycle Fastify (onClose→prisma.$disconnect()).- Pino logger — JSON stdout par défaut, niveaux structurés. Format
{ level, time, pid, hostname, msg, ... }. Pas de PII. Agrégeable par n'importe quel stack (Loki, Datadog, CloudWatch) en v2.
Pas wired en v1 — reportés :
- Helmet (headers sécurité côté API — les 6 headers nginx côté SPA couvrent l'essentiel ; l'API est read-only public).
- Rate limiting global (
@fastify/rate-limit) — démonstration publique, pas de protection DDoS nécessaire pour la démo. Exception assumée : le seul endpoint coûteux (POST /ask, appels LLM payants) est protégé par un rate-guard maison ciblé (plafond coût + fenêtres par IP, zéro dépendance) — cf. §5.B.8.
5.B.5 Entrypoint et boot¶
apps/api/entrypoint.sh orchestre le démarrage du container API :
prisma migrate deploy— fatal en cas d'échec (set -eu). Démarrer contre un schéma stale est pire que ne pas démarrer.prisma db seed— siSEED_ON_BOOT=true. Tolérant à l'échec (warn puis continue). Self-healing contre perte de données (post-mortem 2026-04-21).exec node dist/index.js—execpour quetini(PID 1) forwarde les signaux directement à Node.
Le seed n'insère que les tables de contexte (stations, glaciers, captages, seuils) via upsert idempotent. Les tables opérationnelles (Measurement, IngestionRun, Alert) sont exclusivement alimentées par le cron. Un re-seed ne détruit jamais l'historique ingéré.
5.B.6 Couche IA — narration (ADR-012, extension A)¶
Couche additive et isolée : elle consomme les sorties existantes sans modifier le flux LINDAS → Prisma → API. Flux d'un appel GET /stations/:id/narrative :
- Mesures —
narrative-service.tsréutilisegetStationMeasurements(raw, fenêtre[from, to]) — d'où le 404 station inconnue hérité. - Features —
ai/narration-features.ts(fonction pure) calcule les indicateurs groundés : delta absolu (+ %), valeurs début/fin, min/max, complétude (points présents/attendus,sparse, plus grand trou), statut vs seuils (NORMAL/VIGILANCE/ALERTounull), tendance classifiée. Cadence de complétude :INGESTION_INTERVAL_MINUTES(adossée au cron,ingestion/cadence.ts). - Garde-fou — données insuffisantes (
!hasDataou pas de delta) ⇒state: 'unavailable'(reason: 'insufficient_data'), aucun appel LLM. - Narration —
ai/narration-service.tscalcule uninputHash(sha256 de features +PROMPT_VERSION+ langue + modèle) et lit le cacheInsight; cache hit ⇒ pas d'appel LLM (state: 'cached'). Sinon il appelle Mistral via l'interface isoléeLlmClient(ai/llm-client.ts), persiste l'Insight(texte + provenance + tokens/latence ;costUsdnull jusqu'à D) et renvoiestate: 'generated'. - Grounding STRICT — le LLM ne reçoit que les features ; il les reformule, ne prédit ni n'invente. Sortie étiquetée « résumé assisté par IA » côté UI.
- Dégradation gracieuse — toute erreur LLM (
config/network/http/timeout/parse) devientstate: 'unavailable'(reason: 'llm_error' | 'config_error'), jamais une 5xx : l'enrichissement est non-critique et ne pollue pas le monitoring d'erreurs serveur.
Interface LlmClient : A appelle Mistral directement (clé MISTRAL_API_KEY, server-side only).
Observabilité LLM (D) — un décorateur ObservingLlmClient enveloppe le client (interface inchangée) et persiste un LlmCallRun par appel (succès comme échec) : provider, modèle, tokens, coût estimé (computeCostUsd, tarifs publics Mistral), latence. Il remplit aussi LlmCompletion.costUsd → Insight.costUsd renseigné. Les erreurs de persistance sont avalées (l'observabilité ne casse jamais la feature). Agrégé par GET /ai/status. Le client est LiteLLM-ready (OpenAI-compatible via LITELLM_BASE_URL, derrière la même interface) mais le proxy LiteLLM n'est pas déployé — point d'extension assumé (ADR-012).
5.B.7 Détection d'anomalie statistique (ADR-012, extension B)¶
Cadrage honnête :
apps/api/src/anomaly/est statistique, pas « IA ». Aucun appel LLM n'entre dans la décision d'anomalie ; le verdict est un z-score pur et déterministe. Si le LLM intervenait un jour, il ne ferait que narrer un verdict déjà produit ici.
Couche additive et isolée, sur le même principe que la narration : elle lit après l'upsert d'ingestion et ne modifie jamais le flux LINDAS → Prisma.
- Détection pure —
anomaly/anomaly-detection.ts(detectAnomaly, sans I/O niDate.now(), entièrement unit-testable). Méthode : z-score déseasonnalisé par heure-de-jour UTC (B1-bis). Le point candidat (le plus récent) est comparé non à toute la fenêtre, mais à la distribution des points de la même heure UTC des jours précédents — « ce creux de 06:00 vs les creux de 06:00 des jours d'avant ». Les rivières glaciaires ont un cycle diurne marqué ; un z-score sur fenêtre entière flaguerait chaque creux diurne comme un fauxBELOW. Le bucketing horaire retire cette saisonnalité : seul un point inhabituel pour son heure fire. Seuils :|z| ≥ 2ouvre (3 →ALERT), hystérésis|z| < 1,5ferme (anti-flapping) ; gardesminBucketSamples(≈ 20 points même-heure) et σ≈0 →nullhonnête plutôt que fire sur un σ fragile (cold-start). - Scan post-ingestion —
anomaly/anomaly-scan.ts(runAnomalyScan) parcourt chaque station LIVE × capteur actif, charge 14 j de mesures (le scan surcharge à 14 j la fenêtre de référence du détecteur — dont le défaut interne est 7 j — pour que chaque bucket horaire ait ~2 semaines de points), et délègue àdetectAnomaly. Coquille I/O fine autour de la fonction pure. Lancé après le tick d'ingestion et isolé de lui : un échec de détection ne bloque ni ne corrompt le chemin critique. - Réconciliation épisodique —
services/alerts-service.tsmaintient l'invariant une seule alerte ouverte par{station, paramètre}: un verdict sans alerte ouverte en ouvre une ; un verdict avec alerte ouverte la met à jour en place ; un retour à la normale la ferme (closedAt). Les stats groundées du verdict (mean/std/z/hourBucket/bucketSampleSize/fenêtre) sont persistées dansAlert.metadatapour transparence et UI. - Réactivation sans migration — le modèle
Alertet l'enumSTATISTICAL_ANOMALYexistent depuis la migrationinit(2026-04-20) ; B les active sans aucune migration destructrice — le schéma initial anticipait l'extension.
Limites assumées (revendiquées dans l'UI, lignée ADR-008) : seuils/hystérésis non calibrés par une expertise hydrologique. Effet mesuré de la déseasonnalisation (10 j réels, 6 stations LIVE) : la synchronicité diurne des BELOW chute (CV inter-heures 0,61 → 0,30), le nombre d'épisodes réels reste ~stable (14 → 15) ; σ resserré ⇒ plus sensible aux tendances multi-jours (une vraie récession bassin fire à toutes les heures, à raison).
5.B.8 Chat sur données structurées (ADR-012, extension C)¶
Cadrage honnête : c'est du chat sur données structurées par function-calling, retrieval-augmenté au sens large (le LLM reçoit des faits récupérés en base avant de répondre) mais jamais vectoriel — ni embeddings, ni vector store. Le LLM choisit parmi 4 fonctions typées whitelistées ; il n'écrit jamais de requête (pas de text-to-SQL). C'est aussi le seul endroit du backend où la frontière hexagonale (ports/adapters) est réelle — ailleurs le code reste volontairement plat (YAGNI, ADR-003).
Le module apps/api/src/ai/chat/ est une couche additive et isolée, comme la narration et l'anomalie. Cinq blocs :
QueryPort— la frontière hexagonale (C1).query-port.tsest une interface de domaine pure : elle ne connaît ni Prisma, ni Fastify, ni aucune infrastructure. Le port est la whitelist : les 4 — et seulement 4 — fonctions que le LLM peut appeler (D6) :
| Fonction | Rôle | Retour |
|---|---|---|
findStations |
résolveur nom → id | identité SANS valeurs : id, name, riverName, dataSource, paramètres disponibles |
getLatestMeasurements |
dernière valeur par paramètre | parameter + valeur + unit + status + recordedAt |
getMeasurementStats |
agrégat sur fenêtre [from, to) |
first/last/min/max/avg/deltaAbs/deltaPct/sampleSize (+ unit, fenêtre) ; null = absence honnête |
listAlerts |
alertes (réutilise B) | AlertDTO[] (status open|closed|all, stationId?) |
La séparation identité (sans valeurs) / valeurs est volontaire : findStations résout les noms sans divulguer de mesures ; le LLM doit explicitement demander les valeurs via les 3 autres fonctions.
-
PrismaQueryAdapter— la seule impl infrastructure (C1/D2).prisma-query-adapter.tsimplémente le port en déléguant aux services existants (listStations,listAlerts) + un seul helper d'agrégation (réutilise le pattern degetStationMeasurements). Pas de réécriture de requêtes. UnQueryPorten mémoire (fake) prouve que tout le raisonnement de chat se teste sans DB. -
Tools + dispatcher (C3a).
tools.tsporte les specs function-calling des 4 fonctions (schémas exposés au LLM) + un dispatcher qui route un tool-call validé vers la méthodeQueryPortcorrespondante. Hors whitelist → refus, jamais d'exécution arbitraire.chat-prompt.tsporte le prompt grounding-strict. -
Orchestrateur (C3b).
chat-service.tspilote la boucle tool-calling entre Mistral et leQueryPort. À chaque round le modèle répond en prose (terminé) ou demande des tool-calls ; le dispatcher les exécute, leurs résultats sont réinjectés, le modèle est rappelé. Boucle bornée DUR à 2 rounds (D5) : le 3ᵉ appel LLM est forcé entool_choice: 'none'→ il ne peut produire que la réponse finale, jamais de 4ᵉ appel. Le service reste Prisma-free (il dépend duQueryPort), donc testable avec un fake en mémoire. -
Rate-guard unifié maison (C4).
rate-guard.tsest un seul garde testable (zéro dépendance) qui unifie deux protections du budget LLM, dans l'ordre : (1) plafond coût $0,50/jour sur lecostUsdTodayTOTAL (narration A + chat C), lu en read-only depuis la même source d'observabilité que/ai/status(D) — au-delà → refuscost_capsans consommer de slot ni émettre d'appel LLM (disjoncteur, pas quota) ; (2) fenêtres fixes par IP : 5/min & 20/jour — un slot n'est consommé que si la requête passe. Tout le temporel (now,getCostUsdToday) est injecté → garde déterministe en test, zéro DB, zéro flakiness d'horloge. Choix maison ciblé et défendable (cohérent avec « rate limiting global non wired » du non-scope).
Observabilité (réutilise D). Chaque appel LLM du chat passe par le décorateur ObservingLlmClient avec operation: 'chat' → un LlmCallRun par appel (coût/latence/tokens), agrégé dans /ai/status et compté dans le plafond coût ci-dessus. Aucune table ni secret nouveaux : C réutilise MISTRAL_API_KEY et l'observabilité D.
Grounding STRICT (étendu à C). Hors du périmètre des 4 fonctions, la réponse est « je ne peux pas répondre » — le LLM ne brode pas sur des données qu'il n'a pas récupérées. Seule exception bornée (chat-v3, chat-prompt.ts) : une méta-question (« que peux-tu faire ? ») obtient une description du périmètre — les 4 capacités et 1–2 exemples — sans aucun appel de fonction ; le grounding reste absolu pour toute vraie question (une demande de météo ou de prévision reste un refus). La trace used (tools réellement appelés) est renvoyée au front pour transparence.
5.B.9 Extension énergie — tarifs ElCom (ADR-013)¶
Module additif et isolé : aucun modèle hydrologique n'est modifié. Il lit les cubes ElCom sur le même endpoint LINDAS que l'hydrologie, via le client générique ingestion/sparql-client.ts.
- Import manuel, jamais automatique.
energy/import-elcom-cli.tsest une commande explicite (pnpm --filter @alpimonitor/api import:elcomen dev,node dist/energy/import-elcom-cli.jsen prod). Aucun cron, aucun appel au boot ni depuis le seed. Périmètre : le dernier millésime pour tous les GRD suisses, les quatre précédents pour le Valais, plus les médianes suisse et valaisanne publiées. - Garanties d'import. Idempotent par IRI ElCom ; clé métier unique par table de faits ; échec bruyant au-delà de 1 % de lignes ignorées dans un millésime, si SEDRE SA manque ou si un fait réapparaît sous un nouvel IRI ; chaque passage écrit un
IngestionRun(LINDAS_ELCOM), y compris en échec. - Statistiques pures.
energy/positioning.tscalcule quantiles (définition depercentile_cont), valeur par GRD (médiane de ses tarifs communaux) et verdict sur les quatre bases, en dix-millièmes de centime entiers pour que « égal à la médiane » soit une comparaison exacte. Testé sans base ; un test d'intégration vérifie sur la base de dev les chiffres publiés dans l'ADR. - Cache par import. Lire ~30 000 lignes
Decimalcoûte plus d'une seconde ;energy-service.tsmet les trois réponses en cache par identifiant du dernierIngestionRunréussi. Un nouvel import invalide le cache de lui-même.