# 001 — HistoMétéo MVP — Spécification Technique v9

> **Itération** : v9 — intègre le feedback Reviewer (`feedback-to-architect-001-v8.md`) + nouvelles demandes fonctionnelles « Normales climatiques » et « Enrichissement données communes ».

---

## 0) Contract

- **Source of truth** : ce document (`001-histometeo-mvp.tech.v9.md`)
- **Functional integrity** : aucun critère d'acceptation de `001-histometeo-mvp.md` ne peut être modifié, ignoré ou réinterprété.
- **Scope** : fichiers et dossiers autorisés à créer/modifier :
  - `src/normals_service.py` — **nouveau** — service de calcul des normales climatiques
  - `src/main.py` — nouvelle route `/api/normals`, enrichissement `/api/resolve/`, validation dept sur redirect (R28)
  - `src/commune_service.py` — enrichissement données communes (nouveaux champs geo API), extension `_normalize_commune`, extension `resolve_slug`
  - `src/config.py` — constantes normales (période de référence, TTL, URL), extension `COMMUNES_FIELDS`
  - `public/app.js` — affichage bloc normales/anomalies, affichage bloc infos commune, fetch parallèle normales
  - `public/index.html` — nouvelles sections HTML (normales, infos commune)
  - `public/style.css` — styles des nouveaux blocs
  - `tests/test_normals_service.py` — **nouveau** — tests unitaires normales
  - `tests/test_api.py` — tests nouvelle route `/api/normals` + corrections R24
  - `tests/test_commune_service.py` — tests enrichissement + corrections R25/R26
  - `tests/conftest.py` — extension fixtures si nécessaire pour les mocks normals
- **Forbidden changes** :
  - `src/weather_service.py` — aucune modification
  - `src/cache.py` — aucune modification
  - `docs/` — aucune modification des specs existantes (hors ce fichier)
  - `.github/` — aucune modification
  - `Dockerfile`, `pyproject.toml` — pas de modification
  - `public/assets/` — pas de modification
- **Invariants** (tous hérités et préservés) :
  - INV-1 : Aucune donnée utilisateur stockée (ni serveur, ni client)
  - INV-2 : Heures en fuseau `Europe/Paris`
  - INV-3 : Période maximale 31 jours
  - INV-4 : Aucune clé API
  - INV-5 : Interface en français avec accents (`HistoMétéo`)
  - INV-6b : Page unique, URL reflète l'état via des routes propres (path segments)
  - INV-7 : Aucune injection HTML — `textContent`, `createElement`, `replaceChildren()` uniquement, jamais `innerHTML`
  - INV-8 : Le flux recherche simple fonctionne indépendamment du mode comparaison
  - **INV-9** (nouveau) : Les normales climatiques sont un enrichissement progressif — leur indisponibilité ne bloque pas l'affichage des données météo ni la navigation
  - **INV-10** (nouveau) : Les informations commune sont un enrichissement progressif — leur absence partielle (champ manquant) n'empêche pas l'affichage du bloc
- **Done when** :
  - Les 11 critères d'acceptation originaux (AC1–AC11) restent vérifiables
  - Toutes les fonctionnalités v2–v8 restent opérationnelles (URLs SEO, redirections 301, canonical, comparaison)
  - `/api/normals?lat=...&lon=...&start=YYYY-MM-DD&end=YYYY-MM-DD` retourne les normales climatiques agrégées et journalières
  - Le bloc « Anomalie climatique » s'affiche après une recherche simple avec les écarts température et précipitations
  - Le bloc « Informations sur la commune » s'affiche après une recherche avec population, superficie, département, région, altitude
  - `/api/resolve/{slug}` retourne les champs enrichis (population, superficie, département nom, région)
  - Si l'API Open-Meteo est indisponible pour les normales, le reste de la page fonctionne normalement (INV-9)
  - Les corrections R24–R26 et R28 du feedback v8 sont intégrées
  - **Tous** les tests backend passent (anciens + nouveaux)
  - L'application se lance via `uvicorn src.main:app` ou `docker compose up`

---

## 1) Objectif technique

Ajouter deux enrichissements à la page de résultats :

1. **Normales climatiques** : calculer les moyennes historiques (1991–2020) pour la localisation et la période demandées via l'API Open-Meteo Archive, puis afficher les anomalies (écart observé vs. normale).
2. **Informations commune** : étendre les données renvoyées par l'API geo.api.gouv.fr pour afficher population, superficie, département, région et altitude.

Ces enrichissements sont **progressifs** : leur absence ou échec ne dégrade pas l'expérience météo existante.

Intégrer également les corrections mineures du feedback v8 (R24–R28).

---

## 2) Analyse du brief

### Besoins principaux

| Besoin                                    | Source                | Complexité | Impact                     |
| ----------------------------------------- | --------------------- | ---------- | -------------------------- |
| Normales climatiques (calcul + affichage) | Demande fonctionnelle | Élevée     | Valeur informationnelle    |
| Anomalies (température + précipitations)  | Demande fonctionnelle | Faible     | Interprétation des données |
| Enrichissement commune (population, etc.) | Demande fonctionnelle | Faible     | SEO + contexte             |
| Altitude via Open-Meteo                   | Demande fonctionnelle | Faible     | Contexte géographique      |
| Corrections feedback v8 (R24–R28)         | Feedback Reviewer     | Faible     | Qualité / robustesse       |

### Contraintes

- **Pas de base de données locale** — l'architecture reste stateless. Les normales sont calculées à la volée et cachées en mémoire (TTLCache).
- **Budget API zéro** — Open-Meteo Archive gratuit, geo.api.gouv.fr gratuit.
- **`weather_service.py` interdit en modification** — les normales passent par un service séparé.
- **Le frontend doit fonctionner même si les normales échouent** — progressive enhancement.
- **Les normales d'une période de référence fixe sont immuables** — cache très long TTL justifié.

### Risques

| #   | Risque                                                         | Mitigation                                                                                         |
| --- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| 1   | Appel Open-Meteo trop lent pour 30 ans de données journalières | Découpage en 3 chunks de 10 ans (parallèle). Cache long TTL (90 jours). 1 seul fetch par location. |
| 2   | Open-Meteo indisponible pour les normales                      | INV-9 : le frontend affiche les résultats météo normalement, le bloc normales est masqué.          |
| 3   | Champs enrichis absents pour certaines communes (geo API)      | INV-10 : chaque champ est optionnel, le bloc commune s'adapte dynamiquement.                       |

---

## 3) Design minimal proposé

### 3.1 Service des normales climatiques (`src/normals_service.py`)

#### Principe

Pour une localisation donnée (lat, lon), récupérer les données journalières Open-Meteo Archive sur la période de référence 1991–2020, pré-calculer les moyennes par jour de l'année (365 entrées), puis extraire les jours correspondant à la période demandée par l'utilisateur.

#### Stratégie de fetch

Le fetch de la période de référence complète (10 957 jours) est découpé en **3 chunks de 10 ans** pour des réponses de taille raisonnable (~3 650 jours chacun) :

| Chunk | Période                 | Jours |
| ----- | ----------------------- | ----- |
| 1     | 1991-01-01 → 2000-12-31 | 3 653 |
| 2     | 2001-01-01 → 2010-12-31 | 3 653 |
| 3     | 2011-01-01 → 2020-12-31 | 3 653 |

Les 3 appels sont lancés en **parallèle** via `asyncio.gather()`.

#### Paramètres Open-Meteo

```
GET https://archive-api.open-meteo.com/v1/archive
  ?latitude={lat}
  &longitude={lon}
  &start_date={chunk_start}
  &end_date={chunk_end}
  &daily=temperature_2m_mean,temperature_2m_max,temperature_2m_min,precipitation_sum
  &timezone=Europe/Paris
```

#### Pré-traitement

Après réception des 3 chunks, fusionner les données journalières et calculer les moyennes par jour de l'année :

```
Pour chaque jour du calendrier (MM-DD, 365 ou 366 entrées) :
    temp_avg  = moyenne(temperature_2m_mean de ce jour-de-l'année across 1991–2020)
    temp_max  = moyenne(temperature_2m_max de ce jour-de-l'année across 1991–2020)
    temp_min  = moyenne(temperature_2m_min de ce jour-de-l'année across 1991–2020)
    precip    = moyenne(precipitation_sum de ce jour-de-l'année across 1991–2020)
```

Le résultat est un dictionnaire `{ "01-01": {temp_avg, temp_max, temp_min, precipitation}, ..., "12-31": {...} }`.

**Gestion du 29 février** : seules les années bissextiles (1992, 1996, 2000, 2004, 2008, 2012, 2016, 2020) contribuent à la moyenne de "02-29". Le dénominateur est le nombre d'années ayant cette date.

**Altitude** : la réponse Open-Meteo contient un champ racine `elevation` (altitude du point de grille en mètres). Il est extrait et stocké avec les normales.

#### Cache

- **Clé** : `normals:{lat:.2f}:{lon:.2f}`
- **Valeur** : `{ elevation: float, days: { "MM-DD": NormalDay, ... } }`
- **TTL** : 90 jours (`NORMALS_CACHE_TTL_SECONDS`)
- **Max entries** : 200

Justification du TTL long : les normales 1991–2020 sont immuables (la période de référence est fixe). Le cache est invalidé uniquement par expiration mécanique.

**Un seul fetch par localisation** : quelle que soit la période demandée par l'utilisateur, les normales couvrent toute l'année. Les requêtes ultérieures pour la même localisation avec une période différente sont servies directement depuis le cache.

#### Interface publique

```python
class NormalsService:
    async def get_normals(
        self, latitude: float, longitude: float, start: str, end: str
    ) -> dict:
        """
        Retourne les normales climatiques pour la localisation et la période.
        start/end au format YYYY-MM-DD.
        """
```

#### Structure de la réponse

```json
{
  "elevation": 608.0,
  "reference_period": "1991-2020",
  "period_normals": {
    "temp_avg": 10.4,
    "temp_max_avg": 14.2,
    "temp_min_avg": 6.8,
    "precipitation_daily_avg": 2.1,
    "precipitation_total": 14.7
  },
  "daily_normals": [
    {
      "month_day": "03-05",
      "temp_avg": 9.8,
      "temp_max": 13.5,
      "temp_min": 6.2,
      "precipitation": 1.9
    }
  ]
}
```

- `period_normals` : moyennes agrégées sur la période demandée par l'utilisateur.
  - `precipitation_daily_avg` : moyenne journalière (mm/jour).
  - `precipitation_total` : somme des moyennes journalières sur la période (mm total attendu).
- `daily_normals` : détail jour par jour (permet un overlay futur sur le graphique).

#### Gestion des erreurs

| Cas                                    | Comportement                                  |
| -------------------------------------- | --------------------------------------------- |
| Open-Meteo indisponible                | Lever `NormalsUpstreamError`                  |
| Coordonnées invalides                  | Lever `NormalsValidationError`                |
| Données manquantes pour certains jours | Calculer la moyenne sur les jours disponibles |
| Aucun jour de référence disponible     | Retourner `None`                              |

#### Classes d'exception

```python
class NormalsValidationError(Exception):
    pass

class NormalsUpstreamError(Exception):
    pass
```

---

### 3.2 Enrichissement des données communes

#### Extension des champs geo.api.gouv.fr

**Actuel** :

```python
COMMUNES_FIELDS = "nom,code,codeDepartement,codesPostaux,centre"
```

**Nouveau** :

```python
COMMUNES_FIELDS = "nom,code,codeDepartement,codesPostaux,centre,population,surface,departement,region"
```

Champs ajoutés :

| Champ         | Type depuis l'API geo       | Traitement                                            |
| ------------- | --------------------------- | ----------------------------------------------------- |
| `population`  | `int`                       | Conservé tel quel (dernier recensement connu)         |
| `surface`     | `float` (hectares)          | Converti en km² : `surface / 100`, arrondi 1 décimale |
| `departement` | `{ code, nom, codeRegion }` | Extraire `nom` → `departement_nom`                    |
| `region`      | `{ code, nom }`             | Extraire `nom` → `region_nom`                         |

#### Extension de `_normalize_commune`

```python
@staticmethod
def _normalize_commune(item: dict[str, Any]) -> dict[str, Any]:
    centre = item.get("centre") or {}
    coordinates = centre.get("coordinates") or [None, None]
    lon = coordinates[0] if len(coordinates) > 0 else None
    lat = coordinates[1] if len(coordinates) > 1 else None

    surface_ha = item.get("surface")
    surface_km2 = round(surface_ha / 100, 1) if surface_ha else None

    dept_obj = item.get("departement") or {}
    region_obj = item.get("region") or {}

    return {
        "nom": item.get("nom", ""),
        "departement": item.get("codeDepartement", ""),
        "latitude": lat,
        "longitude": lon,
        "population": item.get("population"),
        "surface_km2": surface_km2,
        "departement_nom": dept_obj.get("nom"),
        "region_nom": region_obj.get("nom"),
    }
```

**Compatibilité ascendante** : les champs existants (`nom`, `departement`, `latitude`, `longitude`) restent inchangés. Les nouveaux champs sont additionnels. Le frontend existant ignore les champs qu'il n'utilise pas.

#### Extension de `resolve_slug`

Le dictionnaire retourné par `resolve_slug` inclut les nouveaux champs :

```python
resolved = {
    "nom": commune.get("nom", ""),
    "departement": commune.get("departement", ""),
    "latitude": commune.get("latitude"),
    "longitude": commune.get("longitude"),
    "slug": f"{name_slug}-{dept_code}",
    "population": commune.get("population"),
    "surface_km2": commune.get("surface_km2"),
    "departement_nom": commune.get("departement_nom"),
    "region_nom": commune.get("region_nom"),
}
```

---

### 3.3 Route `/api/normals`

```
GET /api/normals?lat={lat}&lon={lon}&start={YYYY-MM-DD}&end={YYYY-MM-DD}
```

**Paramètres** :

| Param   | Type    | Requis | Validation                           |
| ------- | ------- | ------ | ------------------------------------ |
| `lat`   | `float` | oui    | ∈ [-90, 90]                          |
| `lon`   | `float` | oui    | ∈ [-180, 180]                        |
| `start` | `str`   | oui    | Format ISO `YYYY-MM-DD`              |
| `end`   | `str`   | oui    | Format ISO `YYYY-MM-DD`, end ≥ start |

**Réponses** :

| Code | Cas                            | Body                                                                                 |
| ---- | ------------------------------ | ------------------------------------------------------------------------------------ |
| 200  | Succès                         | JSON normales (cf. section 3.1)                                                      |
| 400  | Paramètres invalides/manquants | `{ "error": "..." }`                                                                 |
| 502  | Open-Meteo indisponible        | `{ "error": "Le service de normales climatiques est temporairement indisponible." }` |

**Ordre de déclaration dans `main.py`** (mise à jour section 3.7 de v8) :

1. `GET /api/communes`
2. `GET /api/weather`
3. `GET /api/normals` — **nouveau**
4. `GET /api/resolve/{slug}`
5. `GET /meteo/{slug}/{start}/{end}`
6. `GET /comparaison/{slug1}/vs/{slug2}/{start}/{end}`
7. `GET /`
8. `app.mount("/", StaticFiles(...))`

---

### 3.4 Correction R27 — Pseudo-code `generate_slug`

Le pseudo-code de la v8 (sections 3.1 et 3.6) contenait une erreur : `re.sub(r"[''ʼ\s]+", "-", s)` traite les apostrophes et les espaces de la même manière (→ tiret). L'implémentation correcte (et déjà en production) sépare les deux opérations :

**Algorithme corrigé** (identique backend Python + frontend JS) :

1. Convertir en minuscules
2. Normaliser les accents (NFD → supprimer les combining marks)
3. **Supprimer** les apostrophes (`'`, `'`, `ʼ`) → chaîne vide
4. Remplacer les espaces par des tirets
5. Supprimer tout caractère non `[a-z0-9-]`
6. Réduire les tirets multiples en un seul
7. Supprimer les tirets en début/fin

```python
@staticmethod
def generate_slug(name: str) -> str:
    value = (name or "").lower()
    value = unicodedata.normalize("NFD", value)
    value = re.sub(r"[\u0300-\u036f]", "", value)   # strip accents
    value = re.sub(r"[''ʼ]+", "", value)             # remove apostrophes
    value = re.sub(r"\s+", "-", value)               # spaces → dash
    value = re.sub(r"[^a-z0-9-]", "", value)         # keep only alphanum + dash
    value = re.sub(r"-{2,}", "-", value)             # collapse dashes
    return value.strip("-")
```

Le code existant est déjà conforme. Seul le pseudo-code de la spec est corrigé.

---

### 3.5 Correction R28 — Validation `dept` sur la route `/`

Dans `main.py`, la route `/` construit une URL de redirection 301 à partir du query parameter `dept`. En défense en profondeur, valider que `dept` est bien un code département (2 ou 3 chiffres) avant de l'utiliser dans l'URL.

**Avant** :

```python
slug1 = f"{commune_service.generate_slug(commune)}-{dept.strip()}"
```

**Après** :

```python
dept_clean = dept.strip()
if not re.match(r"^\d{2,3}$", dept_clean):
    return FileResponse(index_file)
slug1 = f"{commune_service.generate_slug(commune)}-{dept_clean}"
```

Même validation pour `dept2` si présent.

---

### 3.6 Affichage frontend — Bloc « Anomalie climatique »

#### Fetch parallèle

Dans `performSearch()`, après avoir lancé le fetch météo, lancer le fetch des normales en parallèle :

```js
async function performSearch() {
  // ... code existant : fetch weather ...

  // Fetch normales en parallèle (non bloquant)
  if (!comparisonMode) {
    fetchAndRenderNormals(
      selectedCommune,
      dateStart.value,
      dateEnd.value,
      dailySummaries,
    );
  }

  // ... suite du rendu existant ...
}
```

`fetchAndRenderNormals` est une fonction autonome qui :

1. Appelle `GET /api/normals?lat={lat}&lon={lon}&start={start}&end={end}`
2. En cas de succès → calcule les anomalies et affiche le bloc
3. En cas d'échec → masque silencieusement le bloc (INV-9)

L'appel se fait via `fetch()` classique. En mode comparaison, le bloc normales n'est **pas affiché** (V2).

#### Calcul des anomalies (frontend)

Le frontend possède déjà les agrégats observés via `computeAggregates(dailySummaries)` qui retourne `temp_min`, `temp_max`, `temp_avg`, `precipitation_total`, etc.

Les anomalies sont calculées par différence :

```js
const anomalies = {
  temp_avg: observed.temp_avg - normals.period_normals.temp_avg,
  temp_max: observed.temp_max - normals.period_normals.temp_max_avg,
  temp_min: observed.temp_min - normals.period_normals.temp_min_avg,
  precipitation:
    observed.precipitation_total - normals.period_normals.precipitation_total,
};
```

#### Rendu du bloc

Nouvelle fonction `renderClimateNormals(normalsData, observedAggregates)` :

- Crée un tableau HTML (via `createElement`) avec 4 lignes :

| Métrique          | Observé | Normale | Anomalie     |
| ----------------- | ------- | ------- | ------------ |
| Temp. moyenne     | 13,2°C  | 10,4°C  | **+2,8°C**   |
| Temp. max moyenne | 17,1°C  | 14,2°C  | **+2,9°C**   |
| Temp. min moyenne | 9,3°C   | 6,8°C   | **+2,5°C**   |
| Précipitations    | 3 mm    | 14,7 mm | **-11,7 mm** |

- Les anomalies positives de température reçoivent une classe CSS `anomaly-warm`.
- Les anomalies négatives de température reçoivent une classe CSS `anomaly-cold`.
- Les anomalies positives de précipitations reçoivent une classe CSS `anomaly-wet`.
- Les anomalies négatives de précipitations reçoivent une classe CSS `anomaly-dry`.
- Le bloc est encadré dans un `<section id="climate-normals">` avec un titre `<h2>Anomalie climatique</h2>` et une mention `(réf. 1991–2020)`.

**Important** : tout le rendu utilise `textContent` / `createElement` / `replaceChildren()` (INV-7).

---

### 3.7 Affichage frontend — Bloc « Informations sur la commune »

#### Source de données

Les données commune sont disponibles depuis :

- `selectedCommune` (résultat de l'autocomplete ou de `fetchResolvedCommune`) — contient les nouveaux champs enrichis (`population`, `surface_km2`, `departement_nom`, `region_nom`).
- `normalsData.elevation` (altitude depuis la réponse normales).

#### Rendu

Nouvelle fonction `renderCommuneInfo(commune, elevation)` :

- Crée un bloc avec les informations disponibles :

| Champ       | Source                                | Format affichage                                     | Fallback si absent           |
| ----------- | ------------------------------------- | ---------------------------------------------------- | ---------------------------- |
| Commune     | `commune.nom`                         | Tel quel                                             | Toujours présent             |
| Département | `commune.departement_nom`             | `"Nom (code)"` ex : `"Alpes-de-Haute-Provence (04)"` | Masqué                       |
| Région      | `commune.region_nom`                  | Tel quel                                             | Masqué                       |
| Population  | `commune.population`                  | Nombre français + ` habitants`                       | Masqué                       |
| Superficie  | `commune.surface_km2`                 | `"XX,X km²"`                                         | Masqué                       |
| Densité     | Calculée : `population / surface_km2` | `"XX hab/km²"`                                       | Masqué si un des deux manque |
| Altitude    | `elevation` (depuis normales)         | `"XXX m"` (arrondi entier)                           | Masqué                       |

- Le bloc est encadré dans un `<section id="commune-info">` avec un titre `<h2>Informations sur la commune</h2>`.
- Le bloc n'est affiché que si au moins un champ enrichi (hors `nom`) est disponible.
- En mode comparaison, le bloc n'est **pas affiché** (V2).

#### Remplissage en deux temps

1. **Immédiat** (après sélection commune) : les champs `population`, `surface_km2`, `departement_nom`, `region_nom` sont disponibles.
2. **Différé** (après réception des normales) : le champ `elevation` est ajouté.

Pour simplifier le V1, le bloc est rendu **une seule fois** après la réception des normales, qui intervient quasi-simultanément au rendu météo.

---

### 3.8 Positionnement des blocs dans la page

Ordre mis à jour des sections de résultats (conformément à la demande fonctionnelle) :

1. Search panel (existant)
2. Résumé journalier — tableau daily summary (existant)
3. **Anomalie climatique** (nouveau — `#climate-normals`)
4. Graphique Chart.js (existant)
5. Détail horaire — expandable day groups (existant)
6. Résumé narratif de la période (existant)
7. **Informations sur la commune** (nouveau — `#commune-info`)
8. Question SEO (existant)
9. Navigation période (existant)
10. Panneau transparence données (existant)
11. Footer (existant)

#### HTML statique (`index.html`)

Ajouter les deux sections (cachées par défaut via `hidden`) dans le HTML :

**Après la section résumé journalier, avant le graphique** :

```html
<section id="climate-normals" class="info-card" hidden>
  <h2>Anomalie climatique</h2>
</section>
```

**Après le résumé narratif, avant la question SEO** :

```html
<section id="commune-info" class="info-card" hidden>
  <h2>Informations sur la commune</h2>
</section>
```

---

### 3.9 Styles CSS des nouveaux blocs

Ajouter dans `public/style.css` les classes suivantes :

```css
.info-card {
  /* Style type carte, cohérent avec .info-panel existant */
}

.anomaly-warm {
  color: var(--color-warm, #d32f2f);
}
.anomaly-cold {
  color: var(--color-cold, #1976d2);
}
.anomaly-wet {
  color: var(--color-wet, #1976d2);
}
.anomaly-dry {
  color: var(--color-dry, #ef6c00);
}
```

Le développeur adapte les valeurs de couleur pour rester cohérent avec la charte existante. Les classes d'anomalie servent uniquement à colorer les valeurs d'écart pour une lecture rapide (rouge = plus chaud, bleu = plus froid/plus humide, orange = plus sec).

---

## 4) Plan d'implémentation

### Étape 1 — Backend : Service des normales (`normals_service.py` + `config.py`)

**Fichiers** : `src/normals_service.py` (nouveau), `src/config.py`

- Créer `NormalsService` avec `get_normals(lat, lon, start, end)`
- Implémenter le fetch en 3 chunks parallèles via `asyncio.gather()`
- Implémenter le pré-traitement day-of-year averages
- Ajouter le cache `normals_cache: TTLCache`
- Ajouter les constantes dans `config.py` :
  - `NORMALS_REFERENCE_START = date(1991, 1, 1)`
  - `NORMALS_REFERENCE_END = date(2020, 12, 31)`
  - `NORMALS_CACHE_TTL_SECONDS = 90 * 24 * 60 * 60` (90 jours)
  - `NORMALS_CACHE_MAX_ENTRIES = 200`
  - `NORMALS_CHUNK_YEARS = 10`
- Ajouter les exceptions `NormalsValidationError`, `NormalsUpstreamError`

**Testable** : `get_normals(44.09, 6.24, "2026-03-05", "2026-03-11")` retourne un dict avec `elevation`, `period_normals`, `daily_normals` (7 entrées).

---

### Étape 2 — Backend : Enrichissement communes (`commune_service.py` + `config.py`)

**Fichiers** : `src/commune_service.py`, `src/config.py`

- Étendre `COMMUNES_FIELDS` pour inclure `population,surface,departement,region`
- Modifier `_normalize_commune` pour extraire les nouveaux champs (population, surface_km2, departement_nom, region_nom)
- Modifier `resolve_slug` pour inclure les nouveaux champs dans le résultat
- **R28** : ajouter la validation regex `^\d{2,3}$` sur `dept` dans la route `/` (étape 3)

**Testable** : `GET /api/communes?q=Paris` retourne des objets avec les champs `population`, `surface_km2`, `departement_nom`, `region_nom` en plus des champs existants.

---

### Étape 3 — Backend : Nouvelle route `/api/normals` + corrections `main.py`

**Fichier** : `src/main.py`

- Instancier `NormalsService` avec le `httpx.AsyncClient`
- Ajouter `GET /api/normals` avec validation des paramètres
- Respecter l'ordre de déclaration (section 3.3)
- Appliquer la correction R28 (validation dept sur redirect)
- Fermer le client normals dans le lifespan shutdown

**Testable** : `GET /api/normals?lat=48.86&lon=2.35&start=2024-01-15&end=2024-01-15` retourne 200 avec les normales. `GET /?commune=Paris&dept=INJECTION&start=2024-01-15&end=2024-01-15` retourne la page d'accueil (pas de redirect).

---

### Étape 4 — Frontend : Fetch et affichage normales + commune info (`app.js`)

**Fichier** : `public/app.js`

- Ajouter `fetchNormals(lat, lon, start, end)` — appel `/api/normals`
- Ajouter `renderClimateNormals(normalsData, observedAggregates)` — bloc anomalies
- Ajouter `renderCommuneInfo(commune, elevation)` — bloc infos commune
- Modifier `performSearch()` pour appeler `fetchAndRenderNormals()` après le rendu météo (non bloquant, mode simple uniquement)
- Le bloc normales appelle `renderCommuneInfo` en fin de chaîne, une fois l'altitude disponible

**Testable** : après une recherche simple, le bloc « Anomalie climatique » apparaît avec les écarts, et le bloc « Informations sur la commune » affiche les données disponibles.

---

### Étape 5 — HTML + CSS : Nouvelles sections (`index.html`, `style.css`)

**Fichiers** : `public/index.html`, `public/style.css`

- Ajouter les sections `#climate-normals` et `#commune-info` dans `index.html` (cachées par défaut)
- Ajouter les styles `.info-card`, `.anomaly-warm`, `.anomaly-cold`, `.anomaly-wet`, `.anomaly-dry` dans `style.css`
- S'assurer que les blocs sont responsive (héritage des media queries existantes)

**Testable** : inspecter le HTML source → les sections sont présentes avec `hidden`. Après une recherche, elles sont visibles et stylées.

---

### Étape 6 — Tests : Normales + enrichissement + corrections feedback

**Fichiers** : `tests/test_normals_service.py` (nouveau), `tests/test_api.py`, `tests/test_commune_service.py`, `tests/conftest.py`

**Nouveaux tests normales** :

| Test                                    | Fichier                   | Assertion                                                                   |
| --------------------------------------- | ------------------------- | --------------------------------------------------------------------------- |
| `test_normals_computation_basic`        | `test_normals_service.py` | Données fake 3 ans → moyennes correctes par jour                            |
| `test_normals_handles_missing_data`     | `test_normals_service.py` | Jours manquants ignorés, moyenne sur les jours disponibles                  |
| `test_normals_caching`                  | `test_normals_service.py` | Deuxième appel même localisation → pas de re-fetch API                      |
| `test_normals_includes_elevation`       | `test_normals_service.py` | Réponse contient `elevation`                                                |
| `test_normals_period_aggregation`       | `test_normals_service.py` | Période 7 jours → agrégats corrects (temp_avg, precip_total)                |
| `test_normals_api_route_ok`             | `test_api.py`             | `GET /api/normals?lat=48.86&lon=2.35&start=2024-03-05&end=2024-03-11` → 200 |
| `test_normals_api_route_missing_params` | `test_api.py`             | Paramètres manquants → 400                                                  |
| `test_normals_api_route_invalid_coords` | `test_api.py`             | `lat=999` → 400                                                             |
| `test_normals_api_route_upstream_error` | `test_api.py`             | Open-Meteo down → 502                                                       |

**Nouveaux tests enrichissement commune** :

| Test                                      | Fichier                   | Assertion                                                                                  |
| ----------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------ |
| `test_normalize_commune_enriched`         | `test_commune_service.py` | `_normalize_commune` retourne `population`, `surface_km2`, `departement_nom`, `region_nom` |
| `test_normalize_commune_missing_enriched` | `test_commune_service.py` | Champs enrichis absents → `None` (pas d'erreur)                                            |
| `test_resolve_slug_enriched_fields`       | `test_commune_service.py` | `resolve_slug` retourne les champs enrichis                                                |

**Corrections feedback v8** :

| Test                                    | Fichier                   | Correction                                                                      |
| --------------------------------------- | ------------------------- | ------------------------------------------------------------------------------- |
| `test_seo_route_comparaison` (existant) | `test_api.py`             | R24 : ajouter `assert "text/html" in response.headers.get("content-type", "")`  |
| `test_resolve_slug_invalid_no_dept`     | `test_commune_service.py` | R25 : `resolve_slug("invalidslug")` → `None`                                    |
| `test_generate_slug_paris`              | `test_commune_service.py` | R26 : `generate_slug("Paris")` == `"paris"`                                     |
| `test_generate_slug_aix`                | `test_commune_service.py` | R26 : `generate_slug("Aix-en-Provence")` == `"aix-en-provence"`                 |
| `test_generate_slug_noisy`              | `test_commune_service.py` | R26 : `generate_slug("Noisy-le-Grand")` == `"noisy-le-grand"`                   |
| `test_generate_slug_empty`              | `test_commune_service.py` | R26 : `generate_slug("")` == `""`                                               |
| `test_legacy_redirect_invalid_dept`     | `test_api.py`             | R28 : `GET /?commune=Paris&dept=EVIL&start=...&end=...` → 200 (pas de redirect) |

**Testable** : `pytest tests/` — tous les tests passent (anciens + nouveaux).

---

## 5) Guide pour le Développeur

### Pièges fréquents

1. **Async gather et gestion d'erreurs** : les 3 chunks de fetch normales sont lancés via `asyncio.gather(*tasks, return_exceptions=True)`. Si un chunk échoue, utiliser les données des chunks restants pour le calcul. Si les 3 échouent → lever `NormalsUpstreamError`.

2. **Parsing des dates pour day-of-year** : utiliser `datetime.strptime(date_str, "%Y-%m-%d").strftime("%m-%d")` pour extraire le `MM-DD`. Attention : le 29 février (`"02-29"`) n'existe que dans les années bissextiles.

3. **Cohérence frontend/backend sur les normales** : le frontend ne calcule PAS les normales côté client. Il appelle l'API backend et affiche les résultats. Le calcul d'anomalies (différence observé - normale) est la seule opération côté client.

4. **Champs enrichis et backward compat** : les champs `population`, `surface_km2`, `departement_nom`, `region_nom` peuvent être `null` pour certaines communes (petites communes, territoires d'outre-mer). Le frontend doit **toujours** tester l'existence du champ avant d'afficher une ligne.

5. **Ordre des routes FastAPI** : la route `/api/normals` doit être déclarée APRÈS `/api/weather` et AVANT `/api/resolve/{slug}`. Respecter l'ordre de la section 3.3.

6. **INV-7** : aucun `innerHTML` dans le rendu des nouveaux blocs. Utiliser exclusivement `createElement`, `textContent`, `replaceChildren()`.

7. **Formatage des nombres en français** : utiliser `Intl.NumberFormat('fr-FR')` pour la population (séparateur de milliers) et les valeurs avec décimales (`minimumFractionDigits`/`maximumFractionDigits`).

### Zones de dérive

- **Ne pas persister les normales dans un fichier ou une base de données locale**. Le cache TTL en mémoire suffit.
- **Ne pas créer de table de communes locales**. Toutes les données transitent par l'API geo.api.gouv.fr + cache.
- **Ne pas afficher les normales en mode comparaison** — c'est V2.
- **Ne pas ajouter de graphique overlay des normales sur le chart existant** — c'est V2. Les `daily_normals` sont exposées dans l'API pour ce futur usage, mais le frontend V1 n'utilise que `period_normals`.
- **Ne pas modifier `weather_service.py`** ni `cache.py`.
- **Ne pas ajouter l'API altimétrique IGN** — l'altitude Open-Meteo (point de grille) suffit pour le V1.

### Décisions explicitement interdites

- Ajouter une dépendance externe pour les normales (numpy, pandas, etc.). Le calcul de moyennes se fait en Python pur.
- Stocker les normales dans `localStorage` ou `sessionStorage` côté client (INV-1).
- Bloquer le rendu des résultats météo en attendant les normales (INV-9).
- Appeler l'API Open-Meteo depuis le frontend (toutes les requêtes Open-Meteo passent par le backend).

---

## 6) Stratégie de tests

### Tests unitaires — `NormalsService`

| Test                                | Données en entrée (mockées)                                          | Résultat attendu                                                               |
| ----------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| `test_normals_computation_basic`    | 3 années de données daily (jan–déc), valeurs connues                 | Moyennes par MM-DD correctes, elevation présente                               |
| `test_normals_handles_missing_data` | 1 année avec des jours manquants (ex: arrays de longueur différente) | Moyenne calculée sur les jours disponibles, pas d'erreur                       |
| `test_normals_caching`              | 2 appels même (lat, lon), mock qui comptabilise les appels API       | 1 seul batch de fetches (pas de re-fetch au 2e appel)                          |
| `test_normals_includes_elevation`   | Réponse Open-Meteo avec `elevation: 608.0`                           | `result["elevation"] == 608.0`                                                 |
| `test_normals_period_aggregation`   | Cache pré-rempli, appel pour une période de 7 jours                  | `period_normals` = moyennes correctes sur 7 jours, `daily_normals` = 7 entrées |

### Tests unitaires — Enrichissement commune

| Test                                      | Données en entrée                                                                                     | Résultat attendu                                                                           |
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `test_normalize_commune_enriched`         | Payload geo API avec `population: 16844, surface: 11709, departement: {nom: ...}, region: {nom: ...}` | `population == 16844, surface_km2 == 117.1, departement_nom == "...", region_nom == "..."` |
| `test_normalize_commune_missing_enriched` | Payload geo API sans `population` ni `surface`                                                        | `population == None, surface_km2 == None` (pas d'erreur)                                   |

### Tests intégration — Routes API

| Route                                                                                      | Attendu                    |
| ------------------------------------------------------------------------------------------ | -------------------------- |
| `GET /api/normals?lat=48.86&lon=2.35&start=2024-03-05&end=2024-03-11`                      | 200 + JSON normales        |
| `GET /api/normals` (sans params)                                                           | 400                        |
| `GET /api/normals?lat=999&lon=0&start=2024-03-05&end=2024-03-11`                           | 400                        |
| `GET /api/normals?lat=48.86&lon=2.35&start=2024-03-05&end=2024-03-11` (mock upstream fail) | 502                        |
| `GET /?commune=Paris&dept=EVIL&start=2024-01-15&end=2024-01-15`                            | 200 HTML (pas de redirect) |

### Tests correctifs feedback v8

| Test                                | Correction | Assertion                                                    |
| ----------------------------------- | ---------- | ------------------------------------------------------------ |
| `test_seo_route_comparaison`        | R24        | `assert "text/html" in response.headers.get("content-type")` |
| `test_resolve_slug_invalid_no_dept` | R25        | `resolve_slug("invalidslug")` → `None`                       |
| `test_generate_slug_paris`          | R26        | `"Paris"` → `"paris"`                                        |
| `test_generate_slug_aix`            | R26        | `"Aix-en-Provence"` → `"aix-en-provence"`                    |
| `test_generate_slug_noisy`          | R26        | `"Noisy-le-Grand"` → `"noisy-le-grand"`                      |
| `test_generate_slug_empty`          | R26        | `""` → `""`                                                  |

### Tests manuels frontend

| #   | Scénario                                    | Résultat attendu                                                           |
| --- | ------------------------------------------- | -------------------------------------------------------------------------- |
| T1  | Recherche Digne-les-Bains, 5-11 mars 2026   | Résultats météo + bloc anomalie avec écarts temp/pluie + bloc commune info |
| T2  | Recherche Paris (75), 15 jan 2024           | Bloc anomalie affiché, bloc commune info avec population ~2M               |
| T3  | Recherche en comparaison (Paris vs Lyon)    | Blocs anomalie et commune info **masqués** (V2)                            |
| T4  | API normales timeout (simulé offline)       | Résultats météo affichés normalement, bloc anomalie masqué                 |
| T5  | Commune sans population (petite commune OM) | Bloc commune info affiché avec les champs disponibles uniquement           |
| T6  | Navigation période (précédent/suivant)      | Bloc anomalie et commune info mis à jour pour la nouvelle période          |
| T7  | Chargement depuis URL SEO directe           | Bloc anomalie et commune info affichés après résolution slug               |
| T8  | Mobile 360px                                | Blocs anomalie et commune info responsifs et lisibles                      |

### Régression

| #   | Scénario                                    | Résultat attendu                          |
| --- | ------------------------------------------- | ----------------------------------------- |
| R1  | Autocomplete commune                        | Fonctionne — champs enrichis transparents |
| R2  | URLs SEO `/meteo/...` et `/comparaison/...` | Fonctionnent identiquement                |
| R3  | Redirections 301 anciennes URLs             | Fonctionnent identiquement                |
| R4  | Toggle « Tout déplier/replier »             | Fonctionne (fix C4 v7 préservé)           |
| R5  | Graphiques Chart.js                         | Affichés correctement                     |
| R6  | `<link rel="canonical">`                    | Présente et correcte                      |

---

## 7) Risques techniques

| #   | Risque                                                               | Mitigation                                                                                                                                                                                        |
| --- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1   | **Temps de calcul initial des normales** (~2-3s pour 3 chunks API)   | Cache TTL 90 jours. Un seul fetch par localisation (couvre toute l'année). Les requêtes suivantes sont instantanées. Le loading state est géré côté frontend (bloc ne bloque pas le rendu météo). |
| 2   | **Réponses Open-Meteo volumineuses** (~200 KB par chunk de 10 ans)   | 3 chunks × 200 KB = 600 KB temporairement en mémoire. Après pré-traitement, le résultat en cache est ~15 KB (365 entrées). L'empreinte mémoire est maîtrisée.                                     |
| 3   | **Rate limit Open-Meteo** (3 appels concurrents par requête normals) | Open-Meteo gratuit autorise 10 000 appels/jour. Avec le cache 90 jours, le nombre de fetches est limité aux premières requêtes par localisation. Le risque est négligeable pour un usage MVP.     |

---

## Annexe — Notes non bloquantes (informatives)

### Reports des itérations précédentes

- **R21** — Les fichiers `public/assets/logo-histometeo.png` et `public/assets/favicon.png` ne sont toujours pas committés. `git add public/assets/` au prochain commit.
- **R22** — Les `DeprecationWarning` des dépendances sur Python 3.14+ persistent. Un upgrade de `pytest-asyncio` / `fastapi` les résoudra.
- **R23** — Les changements v6+v7+v8 sont dans le working tree. Envisager un commit par version.

### V2 (hors scope, pour mémoire)

Les éléments suivants sont **explicitement hors scope v9** mais documentés pour le futur :

- Affichage des normales en mode comparaison (anomalies par commune)
- Overlay des normales journalières sur le graphique Chart.js (les `daily_normals` sont déjà exposées dans l'API pour ce futur usage)
- Table de communes locale (SQLite/JSON) pour des requêtes enrichies sans dépendance à geo.api.gouv.fr
- Altitude précise via l'API altimétrique IGN (au lieu de l'altitude point de grille Open-Meteo)
- Altitude min/max (nécessite données MNT / modèle numérique de terrain)
- Détection automatique d'événements météo remarquables (vagues de chaleur, épisodes de gel, etc.)
- Routes sans suffixe département pour les communes à nom unique
- Pages SEO éditoriales par commune (contenu statique enrichi)
- Routes par jour unique (`/meteo/{slug}/{date}`)
