Spaces:
Running
Running
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
|