File size: 14,056 Bytes
febc39b
 
 
 
 
 
0b746e0
 
 
febc39b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b746e0
febc39b
 
0b746e0
 
febc39b
 
 
 
0b746e0
 
 
 
 
febc39b
 
0b746e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
febc39b
 
0b746e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
febc39b
0b746e0
 
 
 
febc39b
0b746e0
febc39b
 
 
 
 
 
 
 
 
0b746e0
 
febc39b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b746e0
 
febc39b
 
0b746e0
febc39b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0b746e0
 
febc39b
 
 
 
 
 
0b746e0
febc39b
 
 
 
 
0b746e0
 
 
 
febc39b
 
 
 
0b746e0
febc39b
 
 
 
 
 
0b746e0
 
 
 
 
 
 
 
 
 
 
 
 
febc39b
 
 
0b746e0
 
febc39b
 
0b746e0
 
 
 
febc39b
 
0b746e0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
febc39b
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
"""Fake Cube.js client for offline tests.

Reproduces the behaviour of the real Indicateurs API observed on the live Space
(tests/live_capture.json): metadata cube, source cube whose dimensions do NOT
include `libelle`, data cubes named {theme}_{maille} with measures `id_{n}` and
geographic dimensions geocode_*/libelle_*, string-typed numeric values, `limit`,
`order`, `equals` / `contains` / `set` filters. As on the real API, breakdown
columns (secteur, mode_transport…) and `date_mesure` are returned in every row
even when not requested, and a dimension-only query returns distinct rows.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

from src.api_client import BadRequestError, CubeJsClient
from src.models import REGION_CODES

CAPTURE = json.loads((Path(__file__).parent / "live_capture.json").read_text(encoding="utf-8"))

META_DIMS = [
    "id", "libelle", "unite", "description", "methode_calcul", "annees_disponibles",
    "mailles_disponibles", "maille_mini_disponible", "couverture_geographique",
    "completion_region", "completion_departement", "completion_epci", "completion_commune",
    "thematique_fnv",
]
# As on the real API: no `libelle` dimension on the source cube.
SOURCE_DIMS = [
    "id_indicateur", "nom_source", "description", "producteur_source",
    "distributeur_source", "license_source", "lien_page", "date_derniere_extraction",
]
GEO_DIMS = {
    "commune": ["geocode_commune", "libelle_commune"],
    "epci": ["geocode_epci", "libelle_epci"],
    "departement": ["geocode_departement", "libelle_departement"],
    "region": ["geocode_region", "libelle_region"],
}
ALL_LEVELS = ["commune", "epci", "departement", "region"]
# cube -> (levels, indicator ids) — from the captured available_cubes
DATA_CUBES = {
    "conso_enaf_com": (ALL_LEVELS, [611, 545]),
    "stationnement_velo_habitant_com": (ALL_LEVELS, [94]),
    "surfaces_agricoles_com": (["commune"], [23]),
    "surface_bio_epci": (["epci", "region"], [23]),
    "surface_bio_dpt": (["departement", "region"], [23, 42]),
    "dechets_population_dpt": (["departement", "region"], [42]),
    "emission_ges_secteur_com": (ALL_LEVELS, [1]),
    "parts_modales_actifs_com": (ALL_LEVELS, [1053, 955]),
    # commune/EPCI indicator whose commune cube also carries département/région columns
    "climadiag_vague_chaleur_com": (ALL_LEVELS, [1298]),
    "climadiag_vague_chaleur_epci": (["epci"], [1298]),
}
YEARS = ["2020", "2021", "2022"]
CUBE_YEARS = {"climadiag_vague_chaleur_com": ["2005", "2030", "2050", "2100"],
              "climadiag_vague_chaleur_epci": ["2005", "2030", "2050", "2100"]}
# Breakdown columns, always returned by the API
CUBE_AXES = {
    "emission_ges_secteur_com": {"secteur": ["Agriculture", "Déchets", "Energie", "Industrie hors Energie",
                                             "Résidentiel", "Tertiaire", "Transports"]},
    "parts_modales_actifs_com": {"mode_transport": ["Marche", "Transports en commun", "Vélo (depuis 2017)", "Voiture"]},
}
EXTRA_MEASURES = {"climadiag_vague_chaleur_com": ["scenario_bas", "scenario_haut"],
                  "climadiag_vague_chaleur_epci": ["scenario_bas", "scenario_haut"]}
# Metadata not in the capture (axes, summable…), as returned by the real API
META_OVERRIDES: dict[int, dict[str, Any]] = {
    1: {"noms_axes": "secteur", "summable": True,
        "valeur_axes": CUBE_AXES["emission_ges_secteur_com"], "annees_disponibles": "2020, 2021, 2022"},
    1053: {"libelle": "Part d'actifs selon le mode de transport principalement utilisé pour aller travailler",
           "unite": "%", "noms_axes": "mode_transport", "summable": False,
           "valeur_axes": CUBE_AXES["parts_modales_actifs_com"], "mailles_disponibles": "commune, epci, departement, region",
           "annees_disponibles": "2020, 2021, 2022"},
    955: {"noms_axes": "", "summable": False, "unite": "%", "annees_disponibles": "2020, 2021, 2022"},
    1298: {"libelle": "Nombre annuel de jours en vague de chaleur", "unite": "jours", "summable": False,
           "mailles_disponibles": "commune, epci", "annees_disponibles": "2005, 2030, 2050, 2100",
           "completion_epci": 0.82},
}

# Synthetic territories (enough to exercise truncation on communes)
METRO_REGIONS = [code for code in REGION_CODES if not code.startswith("0")]
DEPARTEMENTS = dict.fromkeys(
    [(f"{i:02d}", f"Département {i:02d}") for i in range(1, 96) if i not in (13, 20, 22, 69)]
    + [("2A", "Corse-du-Sud"), ("2B", "Haute-Corse"), ("13", "Bouches-du-Rhône"), ("69", "Rhône"),
       ("22", "Côtes-d'Armor"), ("974", "La Réunion")]
)
EPCIS = {
    "200046977": "Métropole de Lyon",
    "200054781": "Métropole du Grand Paris",
    "200054807": "Métropole d'Aix-Marseille-Provence",
    "243100518": "Toulouse Métropole",
    "243400017": "Montpellier Méditerranée Métropole",
    "200069409": "Communauté d'agglomération Saint-Brieuc Armor Agglomération",
    "249740119": "Communauté intercommunale du Nord de La Réunion",
    **{f"2000{i:05d}": f"Communauté de communes n°{i}" for i in range(1, 60)},
}
DPT_REGION = {"75": "11", "93": "11", "13": "93", "69": "84", "31": "76", "34": "76",
              "22": "53", "35": "53", "56": "53", "974": "04"}
# commune: (label, epci, département)
COMMUNES: dict[str, tuple[str, str, str]] = {
    "75056": ("Paris", "200054781", "75"),
    "13055": ("Marseille", "200054807", "13"),
    "69123": ("Lyon", "200046977", "69"),
    "31555": ("Toulouse", "243100518", "31"),
    "34172": ("Montpellier", "243400017", "34"),
    "34057": ("Castelnau-le-Lez", "243400017", "34"),
    "22278": ("Saint-Brieuc", "200069409", "22"),
    "35258": ("Saint-Brieuc-des-Iffs", "200000001", "35"),
    "56208": ("Saint-Brieuc-de-Mauron", "200000002", "56"),
    "93066": ("Saint-Denis", "200054781", "93"),
    "97411": ("Saint-Denis", "249740119", "974"),
    **{f"{i:05d}": (f"Commune {i}", f"2000{i % 59 + 1:05d}", f"{i % 90 + 1:02d}") for i in range(1000, 1700)},
}


def _region_of(dpt: str) -> str:
    return DPT_REGION.get(dpt) or METRO_REGIONS[sum(map(ord, dpt)) % len(METRO_REGIONS)]


TERRITORIES: dict[str, list[tuple[str, str]]] = {
    "region": [(code, REGION_CODES[code]) for code in METRO_REGIONS],
    "departement": list(DEPARTEMENTS),
    "epci": list(EPCIS.items()),
    "commune": [(code, c[0]) for code, c in COMMUNES.items()],
}
DEPARTEMENT_LABELS = dict(TERRITORIES["departement"])


def _indicator_rows() -> list[dict[str, Any]]:
    """All indicators known from the capture, richest metadata when available."""
    rows: dict[int, dict[str, Any]] = {}
    for item in CAPTURE["list_all"]["indicators"]:
        rows[item["id"]] = dict(item)
    for key, det in CAPTURE["details"].items():
        rows[int(key)].update(det["metadata"])
    for ind_id, override in META_OVERRIDES.items():
        rows.setdefault(ind_id, {"id": ind_id}).update(override)
    return [rows[k] for k in sorted(rows)]


class FakeCubeJsClient(CubeJsClient):
    def __init__(self) -> None:
        super().__init__(token="fake-token")
        self.queries: list[dict[str, Any]] = []
        self.fail_meta = False

    async def get_meta(self) -> dict[str, Any]:
        if self.fail_meta:
            raise BadRequestError("meta unavailable")
        cubes = [
            {"name": "indicateur_metadata", "measures": [], "dimensions": [
                {"name": f"indicateur_metadata.{d}"} for d in META_DIMS]},
            {"name": "indicateur_x_source_metadata", "measures": [], "dimensions": [
                {"name": f"indicateur_x_source_metadata.{d}"} for d in SOURCE_DIMS]},
        ]
        for cube, (levels, ids) in DATA_CUBES.items():
            dims = ["date_mesure", "annee"] + [d for lv in levels for d in GEO_DIMS[lv]] + list(CUBE_AXES.get(cube, {}))
            measures = [f"id_{i}" for i in ids] + EXTRA_MEASURES.get(cube, [])
            cubes.append({
                "name": cube,
                "measures": [{"name": f"{cube}.{m}"} for m in measures],
                "dimensions": [{"name": f"{cube}.{d}"} for d in dims],
            })
        return {"cubes": cubes}

    async def load(self, query: dict[str, Any]) -> dict[str, Any]:
        self.queries.append(query)
        dims: list[str] = query.get("dimensions", [])
        measures: list[str] = query.get("measures", [])
        limit = int(query.get("limit", 10000))
        filters = query.get("filters", [])
        members = dims + measures
        cube = members[0].split(".")[0] if members else ""

        if cube == "indicateur_metadata":
            rows = [{f"indicateur_metadata.{k}": v for k, v in r.items()} for r in _indicator_rows()]
        elif cube == "indicateur_x_source_metadata":
            for d in dims:
                if d.split(".", 1)[1] not in SOURCE_DIMS:
                    raise BadRequestError(
                        f"Bad request to API. Details: {{'type': 'UserError', 'error': \"'{d.split('.', 1)[1]}' "
                        f"not found for path '{d}'\"}}"
                    )
            rows = [{
                "indicateur_x_source_metadata.id_indicateur": 611,
                "indicateur_x_source_metadata.nom_source": "fichiers_fonciers",
                "indicateur_x_source_metadata.description": "Fichiers fonciers (Cerema)",
                "indicateur_x_source_metadata.producteur_source": "Cerema",
                "indicateur_x_source_metadata.distributeur_source": "Cerema",
                "indicateur_x_source_metadata.license_source": "Licence ouverte",
                "indicateur_x_source_metadata.lien_page": "https://artificialisation.developpement-durable.gouv.fr",
                "indicateur_x_source_metadata.date_derniere_extraction": "2024-06-01",
            }]
        elif cube in DATA_CUBES and not measures and sum(d.split(".", 1)[1].startswith("geocode_") for d in dims) > 1:
            rows = self._hierarchy_rows(cube)
        elif cube in DATA_CUBES:
            rows = self._data_rows(cube, dims, measures)
        else:
            raise BadRequestError(f"Unknown cube {cube}")

        for f in filters:
            member, op, values = f["member"], f["operator"], [str(v) for v in f.get("values", [])]
            if op == "equals":
                rows = [r for r in rows if str(r.get(member)) in values]
            elif op == "contains":
                needle = values[0].lower()
                rows = [r for r in rows if needle in str(r.get(member, "")).lower()]
            elif op == "set":
                rows = [r for r in rows if r.get(member) is not None]
        for member, direction in reversed(list((query.get("order") or {}).items())):
            rows.sort(key=lambda r: str(r.get(member) or ""), reverse=direction == "desc")
        if dims and not measures:
            # Cube.js dedupes dimension-only queries
            seen, uniq = set(), []
            for r in rows:
                key = json.dumps([r.get(d) for d in dims], sort_keys=True, default=str)
                if key not in seen:
                    seen.add(key)
                    uniq.append({d: r.get(d) for d in dims})
            rows = uniq
        return {"data": rows[:limit]}

    def _hierarchy_rows(self, cube: str) -> list[dict[str, Any]]:
        """One row per commune with its EPCI, département and région (territory index)."""
        rows = []
        for code, (label, epci, dpt) in COMMUNES.items():
            rows.append({
                f"{cube}.geocode_commune": code, f"{cube}.libelle_commune": label,
                f"{cube}.geocode_epci": epci, f"{cube}.libelle_epci": EPCIS[epci],
                f"{cube}.geocode_departement": dpt,
                f"{cube}.libelle_departement": DEPARTEMENT_LABELS.get(dpt, f"Département {dpt}"),
                f"{cube}.geocode_region": _region_of(dpt),
            })
        return rows

    def _data_rows(self, cube: str, dims: list[str], measures: list[str]) -> list[dict[str, Any]]:
        levels = [lv for lv in GEO_DIMS if f"{cube}.{GEO_DIMS[lv][0]}" in dims]
        if not levels:
            # dimension `annee` only (latest-year lookup): one territory stands for all
            levels = [DATA_CUBES[cube][0][-1]]
        level = levels[0]
        real = CAPTURE["query_611_reg_93"]["data"] if (cube == "conso_enaf_com" and level == "region") else None
        axes = CUBE_AXES.get(cube, {})
        modalities: list[dict[str, str]] = [{}]
        for axis, values in axes.items():
            modalities = [{**m, axis: v} for m in modalities for v in values]
        rows = []
        for code, label in TERRITORIES[level]:
            for year in CUBE_YEARS.get(cube, YEARS):
                for modality in modalities:
                    row = {
                        f"{cube}.{GEO_DIMS[level][0]}": code,
                        f"{cube}.{GEO_DIMS[level][1]}": label,
                        f"{cube}.annee": year,
                        f"{cube}.date_mesure": f"{year}-01-01T00:00:00.000",
                        f"{cube}.date_mesure.year": f"{year}-01-01T00:00:00.000",
                        **{f"{cube}.{a}": v for a, v in modality.items()},
                    }
                    for m in measures:
                        value: Any = f"{(hash((code, year, m, tuple(modality.values()))) % 100000) / 10:.4f}"
                        if real and code == "93":
                            match = next((r for r in real if r["annee"] == year), None)
                            if match:
                                value = match["valeur"]
                        if m.endswith(".id_955") and (modality.get("mode_transport") != "Vélo (depuis 2017)" or year != "2022"):
                            value = None  # « - vélo » indicator: only the vélo modality, since 2017
                        if m.endswith(".id_611") and code == "13" and level == "departement":
                            value = None  # a territory without data at this level
                        row[m] = value
                    rows.append(row)
        return rows