diff --git a/DESCRIPTION b/DESCRIPTION deleted file mode 100644 index 259b5d64..00000000 --- a/DESCRIPTION +++ /dev/null @@ -1,30 +0,0 @@ -Package: MyProjectDependencies -Version: 1.0 -Title: Dependencies for GitHub Actions -Description: Manages R package dependencies for GitHub Actions. -License: GPL-3 -Imports: - arrow, - cluster, - cppRouting, - data.table, - dbscan, - dodgr, - dplyr, - duckdb, - FNN, - future, - future.apply, - geos, - ggplot2, - gtfsrouter, - hms, - jsonlite, - log4r, - lubridate, - nngeo, - osmdata, - remotes, - sf, - sfheaders, - readxl \ No newline at end of file diff --git a/diff.patch b/diff.patch deleted file mode 100644 index 01424737..00000000 Binary files a/diff.patch and /dev/null differ diff --git a/front/README.md b/front/README.md new file mode 100644 index 00000000..d770f51d --- /dev/null +++ b/front/README.md @@ -0,0 +1,23 @@ +# Front — Interface Mobility + +Ce dossier contient l’interface Dash/Mantine de l’application Mobility. + +## Lancer l’interface + +Depuis la racine du projet, exécuter : + +cd front +python -m app.pages.main.main + + +L’application démarre alors en mode développement sur http://127.0.0.1:8050/. + +## Structure simplifiée + +app/components/ — Composants UI (carte, panneaux, contrôles, etc.) + +app/services/ — Services pour la génération des scénarios et l’accès aux données + +app/pages/main/ — Page principale et point d’entrée (main.py) + +app/callbacks.py — Callbacks Dash \ No newline at end of file diff --git a/front/app/scenario/__init__.py b/front/__init__.py similarity index 100% rename from front/app/scenario/__init__.py rename to front/__init__.py diff --git a/front/app/components/features/map/__init__.py b/front/app/components/features/map/__init__.py index e69de29b..b0e3f0e5 100644 --- a/front/app/components/features/map/__init__.py +++ b/front/app/components/features/map/__init__.py @@ -0,0 +1,4 @@ +# Réexporte Map depuis la nouvelle implémentation. +from .map_component import Map + +__all__ = ["Map"] diff --git a/front/app/components/features/map/color_scale.py b/front/app/components/features/map/color_scale.py new file mode 100644 index 00000000..c198d8fb --- /dev/null +++ b/front/app/components/features/map/color_scale.py @@ -0,0 +1,152 @@ +""" +color_scale.py +=============== + +Échelle de couleurs pour la carte des temps moyens de déplacement. + +Ce module fournit : +- une palette **bleu → gris → orange** cohérente avec la légende qualitative + (*Accès rapide* / *Accès moyen* / *Accès lent*) ; +- une dataclass `ColorScale` permettant de convertir une valeur numérique + en couleur RGBA (0–255) et d’obtenir un libellé de légende lisible ; +- une fonction d’ajustement `fit_color_scale()` qui calibre automatiquement + `vmin` / `vmax` à partir d’une série de données (percentiles). + +Fonctionnalités principales +--------------------------- +- `_interp_color(c1, c2, t)` : interpolation linéaire entre deux couleurs RGB. +- `_build_legend_palette(n)` : construit la palette bleu→gris→orange. +- `ColorScale.rgba(v)` : mappe une valeur à un tuple `[R, G, B, A]`. +- `ColorScale.legend(v)` : rend un libellé humain (ex. `"12.3 min"`). +- `fit_color_scale(series)` : ajuste l’échelle à une série pandas (P5–P95). +""" + +from dataclasses import dataclass +import numpy as np +import pandas as pd + + +def _interp_color(c1, c2, t): + """Interpole linéairement entre deux couleurs RGB. + + Args: + c1 (Tuple[int, int, int]): Couleur de départ (R, G, B). + c2 (Tuple[int, int, int]): Couleur d’arrivée (R, G, B). + t (float): Paramètre d’interpolation dans [0, 1]. + + Returns: + Tuple[int, int, int]: Couleur RGB interpolée. + """ + return ( + int(c1[0] + (c2[0] - c1[0]) * t), + int(c1[1] + (c2[1] - c1[1]) * t), + int(c1[2] + (c2[2] - c1[2]) * t), + ) + + +def _build_legend_palette(n=256): + """Construit une palette bleu → gris → orange pour la légende. + + Conçue pour coller à la sémantique : + - bleu : accès rapide (valeurs basses) + - gris : accès moyen (valeurs médianes) + - orange: accès lent (valeurs hautes) + + La palette est générée par interpolation linéaire entre + (bleu→gris) puis (gris→orange). + + Args: + n (int, optional): Nombre total de couleurs dans la palette. + Par défaut `256`. + + Returns: + List[Tuple[int, int, int]]: Liste de couleurs RGB. + """ + blue = ( 74, 160, 205) # accès rapide + grey = (147, 147, 147) # accès moyen + orange = (228, 86, 43) # accès lent + + mid = n // 2 + first = [_interp_color(blue, grey, i / max(1, mid - 1)) for i in range(mid)] + second = [_interp_color(grey, orange, i / max(1, n - mid - 1)) for i in range(n - mid)] + return first + second + + +@dataclass +class ColorScale: + """Échelle de couleurs continue basée sur des bornes min/max. + + Attributs: + vmin (float): Valeur minimale du domaine. + vmax (float): Valeur maximale du domaine. + colors (List[Tuple[int, int, int]]): Palette RGB ordonnée bas→haut. + alpha (int): Canal alpha (0–255). Par défaut `102` (~0.4 d’opacité). + + Méthodes: + rgba(v): Convertit une valeur en `[R, G, B, A]` (uint8). + legend(v): Produit un libellé humain (ex. `"12.1 min"`). + """ + vmin: float + vmax: float + colors: list[tuple[int, int, int]] + alpha: int = 102 # ~0.4 d’opacité + + def rgba(self, v) -> list[int]: + """Mappe une valeur numérique à une couleur RGBA. + + Si `v` est manquante ou si `vmax <= vmin`, retourne une valeur par défaut. + + Args: + v (float | Any): Valeur à convertir. + + Returns: + List[int]: Couleur `[R, G, B, A]` (chaque canal 0–255). + """ + if v is None or pd.isna(v): + return [200, 200, 200, 40] + if self.vmax <= self.vmin: + idx = 0 + else: + t = (float(v) - self.vmin) / (self.vmax - self.vmin) + t = max(0.0, min(1.0, t)) + idx = int(t * (len(self.colors) - 1)) + r, g, b = self.colors[idx] + return [int(r), int(g), int(b), self.alpha] + + def legend(self, v) -> str: + """Retourne un libellé de légende lisible pour la valeur. + + Args: + v (float | Any): Valeur à afficher. + + Returns: + str: Libellé, ex. `"12.3 min"`, ou `"N/A"` si manquant. + """ + if v is None or pd.isna(v): + return "N/A" + return f"{float(v):.1f} min" + + +def fit_color_scale(series: pd.Series) -> ColorScale: + """Ajuste automatiquement une échelle de couleurs à partir d’une série. + + Utilise les percentiles **P5** et **P95** pour définir `vmin` et `vmax`, + afin de diminuer l’influence des valeurs extrêmes. + Si la série est dégénérée (vmin == vmax), retombe sur `(min, max or 1.0)`. + Si la série est vide/invalide, retombe sur le domaine `(0.0, 1.0)`. + + Args: + series (pd.Series): Série de valeurs numériques. + + Returns: + ColorScale: Échelle prête à l’emploi (palette 256 couleurs, alpha 102). + """ + s = pd.to_numeric(series, errors="coerce").dropna() + if len(s): + vmin = float(np.nanpercentile(s, 5)) + vmax = float(np.nanpercentile(s, 95)) + if vmin == vmax: + vmin, vmax = float(s.min()), float(s.max() or 1.0) + else: + vmin, vmax = 0.0, 1.0 + return ColorScale(vmin=vmin, vmax=vmax, colors=_build_legend_palette(256), alpha=102) diff --git a/front/app/components/features/map/components.py b/front/app/components/features/map/components.py new file mode 100644 index 00000000..08474f55 --- /dev/null +++ b/front/app/components/features/map/components.py @@ -0,0 +1,122 @@ +""" +layout.py +========= + +Composants de haut niveau pour la page cartographique : +- `DeckMap` : rendu principal Deck.gl (fond de carte + couches) +- `SummaryPanelWrapper` : panneau latéral droit affichant le résumé d’étude +- `ControlsSidebarWrapper` : barre latérale gauche des contrôles de scénario + +Ce module assemble des éléments d’UI (Dash + Mantine) et des composants +applicatifs (`StudyAreaSummary`, `ScenarioControlsPanel`) afin de proposer +une mise en page complète : carte plein écran, résumé latéral et sidebar. +""" + +import dash_deck +from dash import html +import dash_mantine_components as dmc + +from .config import HEADER_OFFSET_PX, SIDEBAR_WIDTH +from app.components.features.study_area_summary import StudyAreaSummary +from app.components.features.scenario_controls import ScenarioControlsPanel + +from .tooltip import default_tooltip + + +def DeckMap(id_prefix: str, deck_json: str) -> dash_deck.DeckGL: + """Crée le composant Deck.gl plein écran. + + Args: + id_prefix (str): Préfixe utilisé pour l’identifiant Dash. + deck_json (str): Spécification Deck.gl sérialisée (JSON) incluant + carte de fond, couches, vues, etc. + + Returns: + dash_deck.DeckGL: Composant Deck.gl prêt à l’affichage (pickable, tooltips). + """ + return dash_deck.DeckGL( + id=f"{id_prefix}-deck-map", + data=deck_json, + tooltip=default_tooltip(), + mapboxKey="", + style={ + "position": "absolute", + "inset": 0, + "height": "100vh", + "width": "100%", + }, + ) + + +def SummaryPanelWrapper(zones_gdf, id_prefix: str): + """Enveloppe le panneau de résumé global à droite de la carte. + + Args: + zones_gdf: GeoDataFrame (ou équivalent) contenant les colonnes utilisées + par `StudyAreaSummary` (temps moyen, parts modales, etc.). + id_prefix (str): Préfixe d’identifiant pour les composants liés à la carte. + + Returns: + dash.html.Div: Conteneur du panneau de résumé (`StudyAreaSummary`). + """ + return html.Div( + id=f"{id_prefix}-summary-wrapper", + children=StudyAreaSummary(zones_gdf, visible=True, id_prefix=id_prefix), + ) + + +def ControlsSidebarWrapper(id_prefix: str): + """Construit la barre latérale gauche contenant les contrôles du scénario. + + La sidebar est positionnée sous l’en-tête principal (offset vertical défini + par `HEADER_OFFSET_PX`) et utilise une largeur fixe `SIDEBAR_WIDTH`. Elle + embarque le panneau `ScenarioControlsPanel` (rayon, zone INSEE, modes, bouton). + + Args: + id_prefix (str): Préfixe d’identifiant pour éviter les collisions Dash. + + Returns: + dash.html.Div: Conteneur sidebar avec un `dmc.Paper` et le panneau de contrôles. + """ + return html.Div( + dmc.Paper( + children=[ + dmc.Stack( + [ + ScenarioControlsPanel( + id_prefix=id_prefix, + min_radius=15, + max_radius=50, + step=1, + default=40, + default_insee="31555", + ) + ], + gap="md", + ) + ], + withBorder=True, + shadow="md", + radius="md", + p="md", + style={ + "width": "100%", + "height": "100%", + "overflowY": "auto", + "overflowX": "hidden", + "background": "#ffffffee", + "boxSizing": "border-box", + }, + ), + id=f"{id_prefix}-controls-sidebar", + style={ + "position": "absolute", + "top": f"{HEADER_OFFSET_PX}px", + "left": "0px", + "bottom": "0px", + "width": f"{SIDEBAR_WIDTH}px", + "zIndex": 1200, + "pointerEvents": "auto", + "overflow": "hidden", + }, + ) diff --git a/front/app/components/features/map/config.py b/front/app/components/features/map/config.py new file mode 100644 index 00000000..94986824 --- /dev/null +++ b/front/app/components/features/map/config.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass + +# ---------- CONSTANTES ---------- +CARTO_POSITRON_GL = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json" +FALLBACK_CENTER = (1.4442, 43.6045) # Toulouse + +HEADER_OFFSET_PX = 80 +SIDEBAR_WIDTH = 340 + +# ---------- OPTIONS ---------- +@dataclass(frozen=True) +class DeckOptions: + zoom: float = 10 + pitch: float = 35 + bearing: float = -15 + map_style: str = CARTO_POSITRON_GL diff --git a/front/app/components/features/map/deck_factory.py b/front/app/components/features/map/deck_factory.py new file mode 100644 index 00000000..19e45cca --- /dev/null +++ b/front/app/components/features/map/deck_factory.py @@ -0,0 +1,99 @@ +""" +deck_factory.py +================ + +Fabrique d’objets Deck.gl (pydeck) pour l’affichage cartographique. + +Ce module assemble : +- l’échelle de couleurs dérivée des temps moyens (`fit_color_scale`) ; +- la couche de zones (`build_zones_layer`) ; +- la vue et l’état initial (centre, zoom, pitch, bearing) ; +- la sérialisation JSON pour intégration dans l’UI Dash. + +Fonctions principales +--------------------- +- `make_layers(zones_gdf)`: construit la liste de couches pydeck. +- `make_deck(scn, opts)`: assemble un objet `pdk.Deck` prêt à l’affichage. +- `make_deck_json(scn, opts)`: renvoie la spécification Deck.gl au format JSON. +""" + +import pydeck as pdk +import pandas as pd +import geopandas as gpd + +from .config import FALLBACK_CENTER, DeckOptions +from .geo_utils import safe_center +from .color_scale import fit_color_scale +from .layers import build_zones_layer + + +def make_layers(zones_gdf: gpd.GeoDataFrame): + """Construit les couches pydeck à partir des zones. + + Utilise `fit_color_scale` pour calibrer la palette sur la colonne + `average_travel_time`, puis crée la couche polygonale via `build_zones_layer`. + + Args: + zones_gdf (gpd.GeoDataFrame): GeoDataFrame des zones avec au moins + la géométrie et, si possible, `average_travel_time`. + + Returns: + List[pdk.Layer]: Liste des couches construites (vide si aucune géométrie valide). + """ + # Palette "classique" (ton ancienne), la fonction fit_color_scale existante suffit + scale = fit_color_scale(zones_gdf.get("average_travel_time", pd.Series(dtype="float64"))) + layers = [] + zl = build_zones_layer(zones_gdf, scale) + if zl is not None: + layers.append(zl) + return layers + + +def make_deck(scn: dict, opts: DeckOptions) -> pdk.Deck: + """Assemble un objet Deck.gl complet (couches + vue initiale). + + Détermine le centre de la vue à partir des géométries des zones (via + `safe_center`) et retombe sur `FALLBACK_CENTER` si indisponible. + + Args: + scn (dict): Scénario contenant `zones_gdf` (GeoDataFrame des zones). + opts (DeckOptions): Options de vue et de style (zoom, pitch, bearing, map_style). + + Returns: + pdk.Deck: Instance pydeck prête à être rendue. + """ + zones_gdf: gpd.GeoDataFrame = scn["zones_gdf"].copy() + + layers = make_layers(zones_gdf) + lon, lat = safe_center(zones_gdf) or FALLBACK_CENTER + + view_state = pdk.ViewState( + longitude=lon, + latitude=lat, + zoom=opts.zoom, + pitch=opts.pitch, + bearing=opts.bearing, + ) + + return pdk.Deck( + layers=layers, + initial_view_state=view_state, + map_provider="carto", + map_style=opts.map_style, + views=[pdk.View(type="MapView", controller=True)], + ) + + +def make_deck_json(scn: dict, opts: DeckOptions) -> str: + """Sérialise la configuration Deck.gl en JSON. + + Pratique pour passer la spec au composant Dash `dash_deck.DeckGL`. + + Args: + scn (dict): Scénario contenant `zones_gdf`. + opts (DeckOptions): Options de vue/style utilisées par `make_deck`. + + Returns: + str: Chaîne JSON représentant l’objet Deck.gl. + """ + return make_deck(scn, opts).to_json() diff --git a/front/app/components/features/map/geo_utils.py b/front/app/components/features/map/geo_utils.py new file mode 100644 index 00000000..5be1055d --- /dev/null +++ b/front/app/components/features/map/geo_utils.py @@ -0,0 +1,62 @@ +import logging +from typing import Optional, Tuple + +import geopandas as gpd +import numpy as np +import pandas as pd +from shapely.geometry import Polygon, MultiPolygon + +logger = logging.getLogger(__name__) + +def ensure_wgs84(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + """Assure EPSG:4326 pour la sortie.""" + g = gdf.copy() + if g.crs is None: + g = g.set_crs(4326, allow_override=True) + elif getattr(g.crs, "to_epsg", lambda: None)() != 4326: + g = g.to_crs(4326) + return g + +def centroids_lonlat(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + """Ajoute colonnes lon/lat calculées en mètres (EPSG:3857) puis reprojetées en 4326.""" + g = gdf.copy() + if g.crs is None: + g = g.set_crs(4326, allow_override=True) + g_m = g.to_crs(3857) + pts_m = g_m.geometry.centroid + pts_ll = gpd.GeoSeries(pts_m, crs=g_m.crs).to_crs(4326) + g["lon"] = pts_ll.x.astype("float64") + g["lat"] = pts_ll.y.astype("float64") + return g + +def safe_center(gdf: gpd.GeoDataFrame) -> Optional[Tuple[float, float]]: + """Calcule un centroïde global robuste en WGS84, sinon None.""" + try: + zvalid = gdf[gdf.geometry.notnull() & gdf.geometry.is_valid] + if zvalid.empty: + return None + centroid = ensure_wgs84(zvalid).geometry.unary_union.centroid + return float(centroid.x), float(centroid.y) + except Exception as e: + logger.warning("safe_center failed: %s", e) + return None + +def fmt_num(v, nd=1): + try: + return f"{round(float(v), nd):.{nd}f}" + except Exception: + return "N/A" + +def fmt_pct(v, nd=1): + try: + return f"{round(float(v) * 100.0, nd):.{nd}f} %" + except Exception: + return "N/A" + +def as_polygon_rings(geom): + """Retourne les anneaux extérieurs d’un Polygon/MultiPolygon sous forme de liste de coordonnées.""" + if isinstance(geom, Polygon): + return [list(geom.exterior.coords)] + if isinstance(geom, MultiPolygon): + return [list(p.exterior.coords) for p in geom.geoms] + return [] diff --git a/front/app/components/features/map/layers.py b/front/app/components/features/map/layers.py new file mode 100644 index 00000000..b28bc93d --- /dev/null +++ b/front/app/components/features/map/layers.py @@ -0,0 +1,72 @@ +from typing import List, Dict +import pandas as pd +import numpy as np +import pydeck as pdk +import geopandas as gpd + +from .geo_utils import ensure_wgs84, as_polygon_rings, fmt_num, fmt_pct + +# ColorScale est supposé fourni ailleurs (fit_color_scale), injecté via deck_factory +# Ici, on ne change pas la palette : on utilise le champ "average_travel_time". + + +def _polygons_records(zones_gdf: gpd.GeoDataFrame, scale) -> List[Dict]: + g = ensure_wgs84(zones_gdf) + out = [] + for _, row in g.iterrows(): + rings = as_polygon_rings(row.geometry) + if not rings: + continue + + zone_id = row.get("transport_zone_id", "Zone inconnue") + insee = row.get("local_admin_unit_id", "N/A") + + avg_tt = pd.to_numeric(row.get("average_travel_time", np.nan), errors="coerce") + total_dist_km = pd.to_numeric(row.get("total_dist_km", np.nan), errors="coerce") + total_time_min = pd.to_numeric(row.get("total_time_min", np.nan), errors="coerce") + + share_car = pd.to_numeric(row.get("share_car", np.nan), errors="coerce") + share_bicycle = pd.to_numeric(row.get("share_bicycle", np.nan), errors="coerce") + share_walk = pd.to_numeric(row.get("share_walk", np.nan), errors="coerce") + share_carpool = pd.to_numeric(row.get("share_carpool", np.nan), errors="coerce") + share_pt = pd.to_numeric(row.get("share_public_transport", np.nan), errors="coerce") + + for ring in rings: + out.append( + { + "geometry": [[float(x), float(y)] for x, y in ring], + "fill_rgba": scale.rgba(avg_tt), + "Unité INSEE": str(insee), + "Identifiant de zone": str(zone_id), + "Temps moyen de trajet (minutes)": fmt_num(avg_tt, 1), + "Niveau d’accessibilité": scale.legend(avg_tt), + "Distance totale parcourue (km/jour)": fmt_num(total_dist_km, 1), + "Temps total de déplacement (min/jour)": fmt_num(total_time_min, 1), + "Part des trajets en voiture (%)": fmt_pct(share_car, 1), + "Part des trajets à vélo (%)": fmt_pct(share_bicycle, 1), + "Part des trajets à pied (%)": fmt_pct(share_walk, 1), + "Part des trajets en covoiturage (%)": fmt_pct(share_carpool, 1), + "Part des trajets en transport en commun (%)": fmt_pct(share_pt, 1), + } + ) + return out + + +def build_zones_layer(zones_gdf: gpd.GeoDataFrame, scale) -> pdk.Layer | None: + polys = _polygons_records(zones_gdf, scale) + if not polys: + return None + return pdk.Layer( + "PolygonLayer", + data=polys, + get_polygon="geometry", + get_fill_color="fill_rgba", + pickable=True, + filled=True, + stroked=True, + get_line_color=[0, 0, 0, 80], + lineWidthMinPixels=1.5, + elevation_scale=0, + opacity=0.4, + auto_highlight=True, + ) diff --git a/front/app/components/features/map/map.py b/front/app/components/features/map/map.py deleted file mode 100644 index d4cd02a5..00000000 --- a/front/app/components/features/map/map.py +++ /dev/null @@ -1,267 +0,0 @@ -import json -import pydeck as pdk -import dash_deck -from dash import html -import geopandas as gpd -import pandas as pd -import numpy as np -from shapely.geometry import Polygon, MultiPolygon -from app.scenario.scenario_001_from_docs import load_scenario - - -# ---------- CONSTANTES ---------- -CARTO_POSITRON_GL = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json" -FALLBACK_CENTER = (1.4442, 43.6045) # Toulouse - - -# ---------- HELPERS ---------- - -def _centroids_lonlat(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: - """Calcule les centroides en coordonnées géographiques (lon/lat).""" - g = gdf.copy() - if g.crs is None: - g = g.set_crs(4326, allow_override=True) - g_m = g.to_crs(3857) - pts_m = g_m.geometry.centroid - pts_ll = gpd.GeoSeries(pts_m, crs=g_m.crs).to_crs(4326) - g["lon"] = pts_ll.x.astype("float64") - g["lat"] = pts_ll.y.astype("float64") - return g - - -def _fmt_num(v, nd=1): - try: - return f"{round(float(v), nd):.{nd}f}" - except Exception: - return "N/A" - - -def _fmt_pct(v, nd=1): - try: - return f"{round(float(v) * 100.0, nd):.{nd}f} %" - except Exception: - return "N/A" - - -def _polygons_for_layer(zones_gdf: gpd.GeoDataFrame): - """ - Prépare les polygones pour Deck.gl : - - geometry / fill_rgba : nécessaires au rendu - - champs “métier” (INSEE/Zone/Temps/Niveau + stats & parts modales) : pour le tooltip - """ - g = zones_gdf - if g.crs is None or getattr(g.crs, "to_epsg", lambda: None)() != 4326: - g = g.to_crs(4326) - - polygons = [] - for _, row in g.iterrows(): - geom = row.geometry - zone_id = row.get("transport_zone_id", "Zone inconnue") - insee = row.get("local_admin_unit_id", "N/A") - travel_time = _fmt_num(row.get("average_travel_time", np.nan), 1) - legend = row.get("__legend", "") - - # Stats “par personne et par jour” - total_dist_km = _fmt_num(row.get("total_dist_km", np.nan), 1) - total_time_min = _fmt_num(row.get("total_time_min", np.nan), 1) - - # Parts modales - share_car = _fmt_pct(row.get("share_car", np.nan), 1) - share_bicycle = _fmt_pct(row.get("share_bicycle", np.nan), 1) - share_walk = _fmt_pct(row.get("share_walk", np.nan), 1) - - color = row.get("__color", [180, 180, 180, 160]) - - if isinstance(geom, Polygon): - rings = [list(geom.exterior.coords)] - elif isinstance(geom, MultiPolygon): - rings = [list(p.exterior.coords) for p in geom.geoms] - else: - continue - - for ring in rings: - polygons.append({ - # ⚙️ Champs techniques pour le rendu - "geometry": [[float(x), float(y)] for x, y in ring], - "fill_rgba": color, - # ✅ Champs métier visibles dans le tooltip (clés FR) - "Unité INSEE": str(insee), - "Identifiant de zone": str(zone_id), - "Temps moyen de trajet (minutes)": travel_time, - "Niveau d’accessibilité": legend, - "Distance totale parcourue (km/jour)": total_dist_km, - "Temps total de déplacement (min/jour)": total_time_min, - "Part des trajets en voiture (%)": share_car, - "Part des trajets à vélo (%)": share_bicycle, - "Part des trajets à pied (%)": share_walk, - }) - return polygons - - -# ---------- DECK FACTORY ---------- - -def _deck_json(): - layers = [] - lon_center, lat_center = FALLBACK_CENTER - - try: - scn = load_scenario() - zones_gdf = scn["zones_gdf"].copy() - flows_df = scn["flows_df"].copy() - zones_lookup = scn["zones_lookup"].copy() - - # Centrage robuste - if not zones_gdf.empty: - zvalid = zones_gdf[zones_gdf.geometry.notnull() & zones_gdf.geometry.is_valid] - if not zvalid.empty: - c = zvalid.to_crs(4326).geometry.unary_union.centroid - lon_center, lat_center = float(c.x), float(c.y) - - # Palette couleur - at = pd.to_numeric(zones_gdf.get("average_travel_time", pd.Series(dtype="float64")), errors="coerce") - zones_gdf["average_travel_time"] = at - finite_at = at.replace([np.inf, -np.inf], np.nan).dropna() - vmin, vmax = (finite_at.min(), finite_at.max()) if not finite_at.empty else (0.0, 1.0) - rng = vmax - vmin if (vmax - vmin) > 1e-9 else 1.0 - t1, t2 = vmin + rng / 3.0, vmin + 2 * rng / 3.0 - - def _legend(v): - if pd.isna(v): - return "Donnée non disponible" - if v <= t1: - return "Accès rapide" - elif v <= t2: - return "Accès moyen" - return "Accès lent" - - def _colorize(v): - if pd.isna(v): - return [200, 200, 200, 140] - z = (float(v) - vmin) / rng - z = max(0.0, min(1.0, z)) - r = int(255 * z) - g = int(64 + 128 * (1 - z)) - b = int(255 * (1 - z)) - return [r, g, b, 180] - - zones_gdf["__legend"] = zones_gdf["average_travel_time"].map(_legend) - zones_gdf["__color"] = zones_gdf["average_travel_time"].map(_colorize) - - # Appliquer la palette au jeu transmis au layer - polys = [] - for p, v in zip(_polygons_for_layer(zones_gdf), zones_gdf["average_travel_time"].tolist() or []): - p["fill_rgba"] = _colorize(v) - polys.append(p) - - # Polygones (zones) - if polys: - zones_layer = pdk.Layer( - "PolygonLayer", - data=polys, - get_polygon="geometry", - get_fill_color="fill_rgba", - pickable=True, - filled=True, - stroked=True, - get_line_color=[0, 0, 0, 80], - lineWidthMinPixels=1.5, - elevation_scale=0, - opacity=0.4, - auto_highlight=True, - ) - layers.append(zones_layer) - - # --- Arcs de flux --- - lookup_ll = _centroids_lonlat(zones_lookup) - flows_df["flow_volume"] = pd.to_numeric(flows_df["flow_volume"], errors="coerce").fillna(0.0) - flows_df = flows_df[flows_df["flow_volume"] > 0] - flows = flows_df.merge( - lookup_ll[["transport_zone_id", "lon", "lat"]], - left_on="from", right_on="transport_zone_id", how="left" - ).rename(columns={"lon": "lon_from", "lat": "lat_from"}).drop(columns=["transport_zone_id"]) - flows = flows.merge( - lookup_ll[["transport_zone_id", "lon", "lat"]], - left_on="to", right_on="transport_zone_id", how="left" - ).rename(columns={"lon": "lon_to", "lat": "lat_to"}).drop(columns=["transport_zone_id"]) - flows = flows.dropna(subset=["lon_from", "lat_from", "lon_to", "lat_to"]) - flows["flow_width"] = (1.0 + np.log1p(flows["flow_volume"])).astype("float64").clip(0.5, 6.0) - - arcs_layer = pdk.Layer( - "ArcLayer", - data=flows, - get_source_position=["lon_from", "lat_from"], - get_target_position=["lon_to", "lat_to"], - get_source_color=[255, 140, 0, 180], - get_target_color=[0, 128, 255, 180], - get_width="flow_width", - pickable=True, - ) - layers.append(arcs_layer) - - except Exception as e: - print("Overlay scénario désactivé (erreur):", e) - - # Vue centrée - view_state = pdk.ViewState( - longitude=lon_center, - latitude=lat_center, - zoom=10, - pitch=35, - bearing=-15, - ) - - deck = pdk.Deck( - layers=layers, - initial_view_state=view_state, - map_provider="carto", - map_style=CARTO_POSITRON_GL, - views=[pdk.View(type="MapView", controller=True)], - ) - - return deck.to_json() - - -# ---------- DASH COMPONENT ---------- - -def Map(): - deckgl = dash_deck.DeckGL( - id="deck-map", - data=_deck_json(), - # Tooltip personnalisé (aucun champ technique) - tooltip={ - "html": ( - "
" - "Zone d’étude
" - "Unité INSEE : {Unité INSEE}
" - "Identifiant de zone : {Identifiant de zone}

" - "Mobilité moyenne
" - "Temps moyen de trajet : {Temps moyen de trajet (minutes)} min/jour
" - "Distance totale parcourue : {Distance totale parcourue (km/jour)} km/jour
" - "Niveau d’accessibilité : {Niveau d’accessibilité}

" - "Répartition modale
" - "Part des trajets en voiture : {Part des trajets en voiture (%)}
" - "Part des trajets à vélo : {Part des trajets à vélo (%)}
" - "Part des trajets à pied : {Part des trajets à pied (%)}" - "
" - ), - "style": { - "backgroundColor": "rgba(255,255,255,0.9)", - "color": "#111", - "fontSize": "12px", - "padding": "8px", - "borderRadius": "6px", - }, - }, - mapboxKey="", - style={"position": "absolute", "inset": 0}, - ) - - return html.Div( - deckgl, - style={ - "position": "relative", - "width": "100%", - "height": "100%", - "background": "#fff", - }, - ) diff --git a/front/app/components/features/map/map_component.py b/front/app/components/features/map/map_component.py new file mode 100644 index 00000000..d8edb78e --- /dev/null +++ b/front/app/components/features/map/map_component.py @@ -0,0 +1,90 @@ +""" +map.py +====== + +Assemblage de la page cartographique (Deck.gl + panneaux latéraux). + +- **Option A (service)** : si `app.services.map_service` est disponible, + on récupère la spécification Deck.gl JSON et les zones via + `get_map_deck_json` / `get_map_zones_gdf`. +- **Option B (fallback)** : sinon, on calcule localement la carte à partir + d’un scénario (`scenario_service.get_scenario`) et de la fabrique Deck (`make_deck_json`). + +Composants intégrés : +- `DeckMap` : rendu Deck.gl plein écran +- `SummaryPanelWrapper` : panneau de résumé (droite) +- `ControlsSidebarWrapper` : barre de contrôles (gauche) +""" + +from dash import html +from .config import DeckOptions +from .components import DeckMap, ControlsSidebarWrapper, SummaryPanelWrapper + +# — Option A : via map_service s’il existe +try: + from app.services.map_service import get_map_deck_json, get_map_zones_gdf + _USE_SERVICE = True +except Exception: + _USE_SERVICE = False + +# — Option B : fallback direct si map_service absent +if not _USE_SERVICE: + from app.services.scenario_service import get_scenario + from .deck_factory import make_deck_json + + def get_map_deck_json(id_prefix: str, opts: DeckOptions) -> str: + """Construit la spec Deck.gl au format JSON à partir d’un scénario local. + + Args: + id_prefix (str): Préfixe d’identifiant (réservé pour compat). + opts (DeckOptions): Options d’affichage (zoom, pitch, style, etc.). + + Returns: + str: Spécification Deck.gl sérialisée (JSON). + """ + scn = get_scenario() + return make_deck_json(scn, opts) + + def get_map_zones_gdf(): + """Récupère les zones du scénario local (fallback).""" + scn = get_scenario() + return scn["zones_gdf"] + + +def Map(id_prefix: str = "map"): + """Assemble la vue cartographique : carte, résumé et sidebar de contrôles. + + Le rendu s’appuie sur `DeckOptions()` pour initialiser l’état de la vue, + puis crée : + - le composant Deck.gl (`DeckMap`) avec la spec JSON, + - le panneau de résumé (`SummaryPanelWrapper`) à droite, + - la barre de contrôles (`ControlsSidebarWrapper`) à gauche. + + Le layout final est un conteneur `Div` en position relative, sur toute + la hauteur de la fenêtre. + + Args: + id_prefix (str, optional): Préfixe d’identifiants pour les composants + associés à la carte. Par défaut `"map"`. + + Returns: + dash.html.Div: Conteneur principal de la page cartographique. + """ + opts = DeckOptions() + deck_json = get_map_deck_json(id_prefix=id_prefix, opts=opts) + zones_gdf = get_map_zones_gdf() + + deckgl = DeckMap(id_prefix=id_prefix, deck_json=deck_json) + summary = SummaryPanelWrapper(zones_gdf, id_prefix=id_prefix) + controls_sidebar = ControlsSidebarWrapper(id_prefix=id_prefix) + + return html.Div( + [deckgl, summary, controls_sidebar], + style={ + "position": "relative", + "width": "100%", + "height": "100vh", + "background": "#fff", + "overflow": "hidden", + }, + ) diff --git a/front/app/components/features/map/tooltip.py b/front/app/components/features/map/tooltip.py new file mode 100644 index 00000000..016daf36 --- /dev/null +++ b/front/app/components/features/map/tooltip.py @@ -0,0 +1,26 @@ +def default_tooltip() -> dict: + return { + "html": ( + "
" + "Zone d’étude
" + "Unité INSEE : {Unité INSEE}
" + "Identifiant de zone : {Identifiant de zone}

" + "Mobilité moyenne
" + "Temps moyen de trajet : {Temps moyen de trajet (minutes)} min/jour
" + "Distance totale parcourue : {Distance totale parcourue (km/jour)} km/jour
" + "Niveau d’accessibilité : {Niveau d’accessibilité}

" + "Répartition modale
" + "Part des trajets en voiture : {Part des trajets en voiture (%)}
" + "Part des trajets à vélo : {Part des trajets à vélo (%)}
" + "Part des trajets à pied : {Part des trajets à pied (%)}
" + "Part des trajets en transport en commun : {Part des trajets en transport en commun (%)}" + "
" + ), + "style": { + "backgroundColor": "rgba(255,255,255,0.9)", + "color": "#111", + "fontSize": "12px", + "padding": "8px", + "borderRadius": "6px", + }, + } diff --git a/front/app/components/features/scenario_controls/__init__.py b/front/app/components/features/scenario_controls/__init__.py new file mode 100644 index 00000000..20e2b71c --- /dev/null +++ b/front/app/components/features/scenario_controls/__init__.py @@ -0,0 +1,6 @@ +from .panel import ScenarioControlsPanel +from .radius import RadiusControl +from .lau_input import LauInput +from .run_button import RunButton +from .transport_modes_inputs import TransportModesInputs +__all__ = ["ScenarioControlsPanel", "RadiusControl", "LauInput", "RunButton", "TransportModesInputs"] diff --git a/front/app/components/features/scenario_controls/lau_input.py b/front/app/components/features/scenario_controls/lau_input.py new file mode 100644 index 00000000..7ecfc4c2 --- /dev/null +++ b/front/app/components/features/scenario_controls/lau_input.py @@ -0,0 +1,26 @@ +import dash_mantine_components as dmc + +def LauInput(id_prefix: str, *, default_insee: str = "31555"): + """Crée un champ de saisie pour la zone d’étude (code INSEE ou LAU). + + Ce composant permet à l’utilisateur d’indiquer le code de la commune ou + unité administrative locale utilisée comme point de référence pour le scénario. + Le champ est pré-rempli avec un code par défaut (par exemple, Toulouse : `31555`) + et conserve les identifiants Dash existants pour compatibilité avec les callbacks. + + Args: + id_prefix (str): Préfixe pour l’identifiant du composant Dash. + L’ID généré est de la forme `"{id_prefix}-lau-input"`. + default_insee (str, optional): Code INSEE ou LAU affiché par défaut. + Par défaut `"31555"`. + + Returns: + dmc.TextInput: Champ de saisie Mantine configuré pour l’entrée du code INSEE/LAU. + """ + return dmc.TextInput( + id=f"{id_prefix}-lau-input", + value=default_insee, + label="Zone d’étude (INSEE)", + placeholder="ex: 31555", + w=250, + ) diff --git a/front/app/components/features/scenario_controls/panel.py b/front/app/components/features/scenario_controls/panel.py new file mode 100644 index 00000000..a9965f16 --- /dev/null +++ b/front/app/components/features/scenario_controls/panel.py @@ -0,0 +1,63 @@ +# app/components/features/.../panel.py +import dash_mantine_components as dmc +from .radius import RadiusControl +from .lau_input import LauInput +from .run_button import RunButton +from .transport_modes_inputs import TransportModesInputs + + +def ScenarioControlsPanel( + id_prefix: str = "scenario", + *, + min_radius: int = 15, + max_radius: int = 50, + step: int = 1, + default: int | float = 40, + default_insee: str = "31555", +): + """Assemble le panneau vertical de contrôle du scénario. + + Ce composant regroupe les principaux contrôles nécessaires à la + configuration d’un scénario de mobilité ou d’analyse territoriale. + Il est organisé verticalement (`dmc.Stack`) et inclut : + - le contrôle du **rayon d’étude** (`RadiusControl`) ; + - la **zone d’étude (INSEE/LAU)** (`LauInput`) ; + - la section des **modes de transport** (`TransportModesInputs`) ; + - le **bouton d’exécution** (`RunButton`). + + Ce panneau constitue la partie principale de l’interface utilisateur permettant + de définir les paramètres du scénario avant de lancer une simulation. + + Args: + id_prefix (str, optional): Préfixe utilisé pour générer les identifiants + Dash des sous-composants. Par défaut `"scenario"`. + min_radius (int, optional): Valeur minimale du rayon d’étude (en km). + Par défaut `15`. + max_radius (int, optional): Valeur maximale du rayon d’étude (en km). + Par défaut `50`. + step (int, optional): Pas d’incrémentation du rayon pour le slider et l’input. + Par défaut `1`. + default (int | float, optional): Valeur initiale du rayon affichée. + Par défaut `40`. + default_insee (str, optional): Code INSEE ou identifiant LAU par défaut de la + zone sélectionnée (ex. `"31555"` pour Toulouse). + + Returns: + dmc.Stack: Composant vertical (`Stack`) regroupant tous les contrôles du panneau scénario. + """ + return dmc.Stack( + [ + RadiusControl( + id_prefix, + min_radius=min_radius, + max_radius=max_radius, + step=step, + default=default, + ), + LauInput(id_prefix, default_insee=default_insee), + TransportModesInputs(id_prefix="tm"), + RunButton(id_prefix), + ], + gap="sm", + style={"width": "fit-content", "padding": "8px"}, + ) diff --git a/front/app/components/features/scenario_controls/radius.py b/front/app/components/features/scenario_controls/radius.py new file mode 100644 index 00000000..820c4efb --- /dev/null +++ b/front/app/components/features/scenario_controls/radius.py @@ -0,0 +1,69 @@ +import dash_mantine_components as dmc + +def RadiusControl( + id_prefix: str, + *, + min_radius: int = 15, + max_radius: int = 50, + step: int = 1, + default: int | float = 40, +): + """Crée un contrôle de sélection du rayon d’analyse (en kilomètres). + + Ce composant combine un **slider** et un **champ numérique synchronisé** + pour ajuster le rayon d’un scénario (ex. rayon d’étude autour d’une commune). + Les identifiants Dash sont conservés pour assurer la compatibilité avec + les callbacks existants. + + - Le slider permet une sélection visuelle du rayon. + - Le `NumberInput` permet une saisie précise de la valeur. + - Les deux sont alignés horizontalement et liés via leur `id_prefix`. + + Args: + id_prefix (str): Préfixe pour les identifiants Dash. + Les IDs générés sont : + - `"{id_prefix}-radius-slider"` + - `"{id_prefix}-radius-input"` + min_radius (int, optional): Valeur minimale du rayon (en km). + Par défaut `15`. + max_radius (int, optional): Valeur maximale du rayon (en km). + Par défaut `50`. + step (int, optional): Pas d’incrémentation pour le slider et l’input. + Par défaut `1`. + default (int | float, optional): Valeur initiale du rayon (en km). + Par défaut `40`. + + Returns: + dmc.Group: Composant Mantine contenant le label, le slider et le champ numérique. + """ + return dmc.Group( + [ + dmc.Text("Rayon (km)", fw=600, w=100, ta="right"), + dmc.Slider( + id=f"{id_prefix}-radius-slider", + value=default, + min=min_radius, + max=max_radius, + step=step, + w=280, + marks=[ + {"value": min_radius, "label": str(min_radius)}, + {"value": default, "label": str(default)}, + {"value": max_radius, "label": str(max_radius)}, + ], + ), + dmc.NumberInput( + id=f"{id_prefix}-radius-input", + value=default, + min=min_radius, + max=max_radius, + step=step, + w=90, + styles={"input": {"textAlign": "center", "marginTop": "10px"}}, + ), + ], + gap="md", + align="center", + justify="flex-start", + wrap=False, + ) diff --git a/front/app/components/features/scenario_controls/run_button.py b/front/app/components/features/scenario_controls/run_button.py new file mode 100644 index 00000000..4ad4c099 --- /dev/null +++ b/front/app/components/features/scenario_controls/run_button.py @@ -0,0 +1,29 @@ +import dash_mantine_components as dmc + +def RunButton(id_prefix: str, *, label: str = "Lancer la simulation"): + """Crée le bouton principal d’exécution du scénario. + + Ce bouton est utilisé pour lancer une simulation ou exécuter une action + principale dans l’interface utilisateur. + Il est stylisé avec une apparence remplie (`variant="filled"`) et s’aligne + à gauche du conteneur. + + Args: + id_prefix (str): Préfixe pour l’identifiant du composant Dash. + Le bouton aura un ID du type `"{id_prefix}-run-btn"`. + label (str, optional): Texte affiché sur le bouton. + Par défaut `"Lancer la simulation"`. + + Returns: + dmc.Button: Composant Mantine représentant le bouton d’action. + """ + return dmc.Button( + label, + id=f"{id_prefix}-run-btn", + variant="filled", + style={ + "marginTop": "10px", + "width": "fit-content", + "alignSelf": "flex-start", + }, + ) diff --git a/front/app/components/features/scenario_controls/scenario.py b/front/app/components/features/scenario_controls/scenario.py new file mode 100644 index 00000000..36ded57f --- /dev/null +++ b/front/app/components/features/scenario_controls/scenario.py @@ -0,0 +1,42 @@ +from .scenario_controls.panel import ScenarioControlsPanel + +def ScenarioControls( + id_prefix: str = "scenario", + min_radius: int = 15, + max_radius: int = 50, + step: int = 1, + default: int | float = 40, + default_insee: str = "31555", +): + """Construit et retourne le panneau de contrôle principal du scénario. + + Cette fonction agit comme un wrapper simple autour de `ScenarioControlsPanel`, + qui gère l’interface utilisateur permettant de configurer les paramètres d’un + scénario (zone géographique, rayon d’analyse, etc.). + Elle définit les valeurs par défaut et simplifie la création du composant. + + Args: + id_prefix (str, optional): Préfixe utilisé pour les identifiants Dash afin + d’éviter les collisions entre composants. Par défaut `"scenario"`. + min_radius (int, optional): Rayon minimal autorisé dans le contrôle (en km). + Par défaut `15`. + max_radius (int, optional): Rayon maximal autorisé dans le contrôle (en km). + Par défaut `50`. + step (int, optional): Pas d’incrémentation du rayon (en km) pour le sélecteur. + Par défaut `1`. + default (int | float, optional): Valeur initiale du rayon affichée par défaut. + Par défaut `40`. + default_insee (str, optional): Code INSEE ou identifiant LAU de la commune + sélectionnée par défaut. Par défaut `"31555"` (Toulouse). + + Returns: + ScenarioControlsPanel: Instance configurée du panneau de contrôle du scénario. + """ + return ScenarioControlsPanel( + id_prefix=id_prefix, + min_radius=min_radius, + max_radius=max_radius, + step=step, + default=default, + default_insee=default_insee, + ) diff --git a/front/app/components/features/scenario_controls/transport_modes_inputs.py b/front/app/components/features/scenario_controls/transport_modes_inputs.py new file mode 100644 index 00000000..6cf2a84c --- /dev/null +++ b/front/app/components/features/scenario_controls/transport_modes_inputs.py @@ -0,0 +1,270 @@ +import dash_mantine_components as dmc +from dash import html + +# ------------------------- +# Données mock : 5 modes (PT inclus) + sous-modes PT +# ------------------------- +MOCK_MODES = [ + { + "name": "À pied", + "active": True, + "vars": { + "Valeur du temps (€/h)": 12, + "Valeur de la distance (€/km)": 0.01, + "Constante de mode (€)": 1, + }, + }, + { + "name": "Vélo", + "active": True, + "vars": { + "Valeur du temps (€/h)": 12, + "Valeur de la distance (€/km)": 0.01, + "Constante de mode (€)": 1, + }, + }, + { + "name": "Voiture", + "active": True, + "vars": { + "Valeur du temps (€/h)": 12, + "Valeur de la distance (€/km)": 0.01, + "Constante de mode (€)": 1, + }, + }, + { + "name": "Covoiturage", + "active": True, + "vars": { + "Valeur du temps (€/h)": 12, + "Valeur de la distance (€/km)": 0.01, + "Constante de mode (€)": 1, + }, + }, + { + "name": "Transport en commun", + "active": True, # coché par défaut + "vars": { + "Valeur du temps (€/h)": 12, + "Valeur de la distance (€/km)": 0.01, + "Constante de mode (€)": 1, + }, + "pt_submodes": { + # 3 sous-modes cochés par défaut + "walk_pt": True, + "car_pt": True, + "bicycle_pt": True, + }, + }, +] + +VAR_SPECS = { + "Valeur du temps (€/h)": {"min": 0, "max": 50, "step": 1}, + "Valeur de la distance (€/km)": {"min": 0, "max": 2, "step": 0.1}, + "Constante de mode (€)": {"min": 0, "max": 20, "step": 1}, +} + +PT_SUB_LABELS = { + "walk_pt": "Marche + TC", + "car_pt": "Voiture + TC", + "bicycle_pt": "Vélo + TC", +} + +PT_COLOR = "#e5007d" # rouge/magenta du logo AREP + + +def _mode_header(mode): + """Crée l'en-tête d'un mode de transport avec case à cocher et tooltip d'avertissement. + + Cette fonction construit un composant `dmc.Group` contenant : + - une case à cocher permettant d'activer ou désactiver le mode ; + - un texte affichant le nom du mode ; + - un tooltip rouge s'affichant si l'utilisateur tente de désactiver tous les modes. + + Args: + mode (dict): Dictionnaire représentant un mode de transport, issu de MOCK_MODES. + + Returns: + dmc.Group: Composant Mantine contenant la case à cocher, le texte et le tooltip. + """ + return dmc.Group( + [ + dmc.Tooltip( + label="Au moins un mode doit rester actif", + position="right", + withArrow=True, + color=PT_COLOR, + opened=False, + withinPortal=True, + zIndex=9999, + transitionProps={"transition": "fade", "duration": 300, "timingFunction": "ease-in-out"}, + id={"type": "mode-tip", "index": mode["name"]}, + children=dmc.Checkbox( + id={"type": "mode-active", "index": mode["name"]}, + checked=mode["active"], + ), + ), + dmc.Text(mode["name"], fw=700), + ], + gap="sm", + align="center", + w="100%", + ) + + +def _pt_submodes_block(mode): + """Construit le bloc des sous-modes pour le transport en commun (TC). + + Crée une pile verticale de cases à cocher correspondant aux sous-modes : + - Marche + TC + - Voiture + TC + - Vélo + TC + + Chaque case est associée à un tooltip rouge indiquant qu'au moins un sous-mode + doit rester activé. + + Args: + mode (dict): Dictionnaire décrivant le mode "Transport en commun" et ses sous-modes. + + Returns: + dmc.Stack: Bloc vertical contenant les sous-modes configurables. + """ + pt_cfg = mode.get("pt_submodes") or {} + rows = [] + for key, label in PT_SUB_LABELS.items(): + rows.append( + dmc.Group( + [ + dmc.Tooltip( + label="Au moins un sous-mode TC doit rester actif", + position="right", + withArrow=True, + color=PT_COLOR, + opened=False, + withinPortal=True, + zIndex=9999, + transitionProps={"transition": "fade", "duration": 300, "timingFunction": "ease-in-out"}, + id={"type": "pt-tip", "index": key}, + children=dmc.Checkbox( + id={"type": "pt-submode", "index": key}, + checked=bool(pt_cfg.get(key, True)), + ), + ), + dmc.Text(label, size="sm"), + ], + gap="sm", + align="center", + ) + ) + return dmc.Stack(rows, gap="xs") + + +def _mode_body(mode): + """Construit le corps (contenu détaillé) d'un mode de transport. + + Ce bloc inclut les paramètres numériques (valeur du temps, distance, constante) + sous forme de champs `NumberInput`. Si le mode est "Transport en commun", + le corps inclut également la section des sous-modes. + + Args: + mode (dict): Dictionnaire décrivant un mode de transport avec ses variables. + + Returns: + dmc.Stack: Bloc vertical avec les variables d'entrée et, si applicable, les sous-modes TC. + """ + rows = [] + # Variables principales + for label, val in mode["vars"].items(): + spec = VAR_SPECS[label] + rows.append( + dmc.Group( + [ + dmc.Text(label), + dmc.NumberInput( + value=val, + min=spec["min"], + max=spec["max"], + step=spec["step"], + style={"width": 140}, + id={"type": "mode-var", "mode": mode["name"], "var": label}, + ), + ], + justify="space-between", + align="center", + ) + ) + # Sous-modes TC + if mode["name"] == "Transport en commun": + rows.append(dmc.Divider()) + rows.append(dmc.Text("Sous-modes (cumulatifs)", size="sm", fw=600)) + rows.append(_pt_submodes_block(mode)) + return dmc.Stack(rows, gap="md") + + +def _modes_list(): + """Construit la liste complète des modes de transport sous forme d'accordéon. + + Chaque item correspond à un mode de transport (piéton, vélo, voiture, etc.) + et contient : + - un en-tête (nom + case à cocher) ; + - un panneau dépliable avec les paramètres et sous-modes. + + Returns: + dmc.Accordion: Accordéon Mantine contenant tous les modes configurables. + """ + items = [ + dmc.AccordionItem( + [dmc.AccordionControl(_mode_header(m)), dmc.AccordionPanel(_mode_body(m))], + value=f"mode-{i}", + ) + for i, m in enumerate(MOCK_MODES) + ] + return dmc.Accordion( + children=items, + multiple=True, + value=[], + chevronPosition="right", + chevronSize=18, + variant="separated", + radius="md", + styles={"control": {"paddingTop": 8, "paddingBottom": 8}}, + ) + + +def TransportModesInputs(id_prefix="tm"): + """Construit le panneau principal "MODES DE TRANSPORT". + + Ce composant est un accordéon englobant la liste complète des modes + et permet à l'utilisateur d'activer, désactiver ou ajuster les paramètres + de chaque mode. + + Args: + id_prefix (str, optional): Préfixe d'identifiants pour les callbacks Dash. + Par défaut "tm". + + Returns: + dmc.Accordion: Accordéon principal contenant tous les contrôles des modes. + """ + return dmc.Accordion( + children=[ + dmc.AccordionItem( + [ + dmc.AccordionControl( + dmc.Group( + [dmc.Text("MODES DE TRANSPORT", fw=700), html.Div(style={"flex": 1})], + align="center", + ) + ), + dmc.AccordionPanel(_modes_list()), + ], + value="tm-root", + ) + ], + multiple=True, + value=[], + chevronPosition="right", + chevronSize=18, + variant="separated", + radius="lg", + styles={"control": {"paddingTop": 10, "paddingBottom": 10}}, + ) diff --git a/front/app/components/features/study_area_summary/__init__.py b/front/app/components/features/study_area_summary/__init__.py new file mode 100644 index 00000000..b76816ab --- /dev/null +++ b/front/app/components/features/study_area_summary/__init__.py @@ -0,0 +1,3 @@ +from .panel import StudyAreaSummary + +__all__ = ["StudyAreaSummary"] diff --git a/front/app/components/features/study_area_summary/kpi.py b/front/app/components/features/study_area_summary/kpi.py new file mode 100644 index 00000000..d3379d19 --- /dev/null +++ b/front/app/components/features/study_area_summary/kpi.py @@ -0,0 +1,62 @@ +""" +kpi.py +====== + +Composants d’affichage des indicateurs clés de performance (KPI) pour la zone d’étude. + +Ce module affiche les statistiques principales issues du scénario de mobilité : +- Temps moyen de trajet quotidien (en minutes) +- Distance totale moyenne (en kilomètres) + +Ces éléments sont utilisés dans le panneau de résumé (`StudyAreaSummary`) +pour donner une vue synthétique des valeurs moyennes agrégées. +""" + +from dash import html +import dash_mantine_components as dmc +from .utils import fmt_num + + +def KPIStat(label: str, value: str): + """Crée une ligne d’affichage d’un indicateur clé (KPI). + + Affiche un libellé descriptif suivi de sa valeur formatée (texte mis en gras). + Utilisé pour représenter une statistique simple telle qu’un temps moyen + ou une distance totale. + + Args: + label (str): Nom de l’indicateur (ex. "Temps moyen de trajet :"). + value (str): Valeur formatée à afficher (ex. "18.5 min/jour"). + + Returns: + dmc.Group: Ligne contenant le label et la valeur du KPI. + """ + return dmc.Group( + [dmc.Text(label, size="sm"), dmc.Text(value, fw=600, size="sm")], + gap="xs", + ) + + +def KPIStatGroup(avg_time_min: float | None, avg_dist_km: float | None): + """Construit le groupe d’indicateurs clés de la zone d’étude. + + Ce composant affiche : + - Le temps moyen de trajet (en minutes par jour) + - La distance totale moyenne (en kilomètres par jour) + + Si les valeurs sont `None`, elles sont formatées en `"N/A"` grâce à `fmt_num()`. + + Args: + avg_time_min (float | None): Temps moyen de trajet en minutes. + avg_dist_km (float | None): Distance totale moyenne en kilomètres. + + Returns: + dmc.Stack: Bloc vertical contenant les deux statistiques principales. + """ + return dmc.Stack( + [ + KPIStat("Temps moyen de trajet :", f"{fmt_num(avg_time_min, 1)} min/jour"), + KPIStat("Distance totale moyenne :", f"{fmt_num(avg_dist_km, 1)} km/jour"), + ], + gap="xs", + ) diff --git a/front/app/components/features/study_area_summary/legend.py b/front/app/components/features/study_area_summary/legend.py new file mode 100644 index 00000000..cd9b0c06 --- /dev/null +++ b/front/app/components/features/study_area_summary/legend.py @@ -0,0 +1,144 @@ +""" +legend.py +========== + +Composants d’affichage pour la légende compacte associée à la carte des temps moyens. + +Ce module permet de visualiser la correspondance entre les valeurs de temps +de déplacement moyen (`average_travel_time`) et leur codage couleur. +La légende est construite avec trois classes qualitatives : +- **Accès rapide** +- **Accès moyen** +- **Accès lent** + +Ainsi qu’un **dégradé continu** allant du bleu (temps faible) au rouge (temps élevé). + +Fonctionnalités principales : +- `_chip()`: Génère un mini-bloc coloré avec son libellé. +- `LegendCompact()`: Construit la légende complète avec les bornes, la barre de dégradé, + et une explication textuelle. +""" + +from dash import html +import dash_mantine_components as dmc +import pandas as pd +from .utils import safe_min_max, colorize_from_range, rgb_str, fmt_num + + +def _chip(color_rgb, label: str): + """Crée un petit bloc coloré (chip) avec un label descriptif. + + Ce composant est utilisé pour représenter chaque classe de la légende, + associant une couleur à une plage de valeurs (par exemple : “Accès rapide — 12–18 min”). + + Args: + color_rgb (Tuple[int, int, int]): Triplet RGB de la couleur du bloc. + label (str): Texte associé à la couleur. + + Returns: + dmc.Group: Composant contenant un carré coloré et son libellé. + """ + r, g, b = color_rgb + return dmc.Group( + [ + html.Div( + style={ + "width": "14px", + "height": "14px", + "borderRadius": "3px", + "background": f"rgb({r},{g},{b})", + "border": "1px solid rgba(0,0,0,0.2)", + "flexShrink": 0, + } + ), + dmc.Text(label, size="sm"), + ], + gap="xs", + align="center", + wrap="nowrap", + ) + + +def LegendCompact(avg_series): + """Construit une légende compacte pour les temps moyens de déplacement. + + La légende affiche : + - Trois classes qualitatives : *Accès rapide*, *Accès moyen* et *Accès lent*. + - Une barre de dégradé continue (du bleu au rouge). + - Les valeurs numériques min/max correspondantes. + - Un court texte explicatif sur l’interprétation des couleurs. + + Si les valeurs sont manquantes ou uniformes, une alerte grisée est affichée. + + Args: + avg_series (pandas.Series | list | ndarray): Série numérique contenant + les temps moyens de déplacement (en minutes). + + Returns: + dmc.Stack | dmc.Alert: + - Une pile verticale (`dmc.Stack`) avec les couleurs, la barre de dégradé + et les libellés si les données sont valides. + - Un message `dmc.Alert` indiquant l’absence de données sinon. + + Example: + >>> LegendCompact(zones_gdf["average_travel_time"]) + # Renvoie un composant Dash contenant la légende complète + """ + vmin, vmax = safe_min_max(avg_series) + if pd.isna(vmin) or pd.isna(vmax) or vmax - vmin <= 1e-9: + return dmc.Alert( + "Légende indisponible (valeurs manquantes).", + color="gray", + variant="light", + radius="sm", + styles={"root": {"padding": "8px"}}, + ) + + rng = vmax - vmin + t1 = vmin + rng / 3.0 + t2 = vmin + 2.0 * rng / 3.0 + + # Couleurs représentatives des trois classes + c1 = colorize_from_range((vmin + t1) / 2.0, vmin, vmax) + c2 = colorize_from_range((t1 + t2) / 2.0, vmin, vmax) + c3 = colorize_from_range((t2 + vmax) / 2.0, vmin, vmax) + + # Couleurs pour le dégradé continu + left = rgb_str(colorize_from_range(vmin + 1e-6, vmin, vmax)) + mid = rgb_str(colorize_from_range((vmin + vmax) / 2.0, vmin, vmax)) + right = rgb_str(colorize_from_range(vmax - 1e-6, vmin, vmax)) + + return dmc.Stack( + [ + dmc.Text("Légende — temps moyen (min)", fw=600, size="sm"), + _chip(c1, f"Accès rapide — {fmt_num(vmin, 1)}–{fmt_num(t1, 1)} min"), + _chip(c2, f"Accès moyen — {fmt_num(t1, 1)}–{fmt_num(t2, 1)} min"), + _chip(c3, f"Accès lent — {fmt_num(t2, 1)}–{fmt_num(vmax, 1)} min"), + html.Div( + style={ + "height": "10px", + "width": "100%", + "borderRadius": "6px", + "background": f"linear-gradient(to right, {left}, {mid}, {right})", + "border": "1px solid rgba(0,0,0,0.15)", + "marginTop": "6px", + } + ), + dmc.Group( + [ + dmc.Text(f"{fmt_num(vmin, 1)}", size="xs", style={"opacity": 0.8}), + dmc.Text("→", size="xs", style={"opacity": 0.6}), + dmc.Text(f"{fmt_num(vmax, 1)}", size="xs", style={"opacity": 0.8}), + ], + justify="space-between", + align="center", + gap="xs", + ), + dmc.Text( + "Plus la teinte est chaude, plus le déplacement moyen est long.", + size="xs", + style={"opacity": 0.75}, + ), + ], + gap="xs", + ) diff --git a/front/app/components/features/study_area_summary/modal_split.py b/front/app/components/features/study_area_summary/modal_split.py new file mode 100644 index 00000000..d3922071 --- /dev/null +++ b/front/app/components/features/study_area_summary/modal_split.py @@ -0,0 +1,105 @@ +""" +modal_split.py +============== + +Composants d’affichage de la répartition modale (part des différents modes de transport). + +Ce module fournit : +- une fonction interne `_row()` pour créer une ligne affichant un label et une part en pourcentage ; +- la fonction principale `ModalSplitList()` qui assemble ces lignes dans un composant vertical Mantine. + +Utilisé dans le panneau de résumé global (`StudyAreaSummary`) pour présenter la +répartition modale agrégée d’une zone d’étude. +""" + +import dash_mantine_components as dmc +from .utils import fmt_pct + + +def _row(label: str, val) -> dmc.Group | None: + """Construit une ligne affichant le nom d’un mode et sa part en pourcentage. + + Ignore les valeurs nulles, invalides ou inférieures ou égales à zéro. + + Args: + label (str): Nom du mode de transport à afficher. + val (float | None): Part correspondante (entre 0 et 1). + + Returns: + dmc.Group | None: Ligne contenant le label et la valeur formatée, ou `None` + si la valeur n’est pas affichable. + """ + if val is None: + return None + try: + v = float(val) + except Exception: + return None + if v <= 0: + return None + return dmc.Group( + [ + dmc.Text(f"{label} :", size="sm"), + dmc.Text(fmt_pct(v, 1), fw=600, size="sm"), + ], + gap="xs", + ) + + +def ModalSplitList( + share_car=None, + share_bike=None, + share_walk=None, + share_carpool=None, + share_pt=None, + share_pt_walk=None, + share_pt_car=None, + share_pt_bicycle=None, +): + """Construit la liste affichant la répartition modale par type de transport. + + Crée un empilement vertical (`dmc.Stack`) de lignes représentant la part de + chaque mode : voiture, vélo, marche, covoiturage, et transports en commun. + Si les transports en commun sont présents, leurs sous-modes (TC + marche, + TC + voiture, TC + vélo) sont affichés en indentation. + + Args: + share_car (float, optional): Part de la voiture. + share_bike (float, optional): Part du vélo. + share_walk (float, optional): Part de la marche. + share_carpool (float, optional): Part du covoiturage. + share_pt (float, optional): Part totale des transports en commun. + share_pt_walk (float, optional): Part du sous-mode "à pied + TC". + share_pt_car (float, optional): Part du sous-mode "voiture + TC". + share_pt_bicycle (float, optional): Part du sous-mode "vélo + TC". + + Returns: + dmc.Stack: Composant vertical contenant les parts modales formatées. + """ + rows = [ + _row("Voiture", share_car), + _row("Vélo", share_bike), + _row("À pied", share_walk), + _row("Covoiturage", share_carpool), + ] + + if (share_pt or 0) > 0: + rows.append( + dmc.Group( + [ + dmc.Text("Transports en commun", fw=700, size="sm"), + dmc.Text(fmt_pct(share_pt, 1), fw=700, size="sm"), + ], + gap="xs", + ) + ) + # Sous-modes (indentés) + sub = [ + _row(" À pied + TC", share_pt_walk), + _row(" Voiture + TC", share_pt_car), + _row(" Vélo + TC", share_pt_bicycle), + ] + rows.extend([r for r in sub if r is not None]) + + rows = [r for r in rows if r is not None] + return dmc.Stack(rows, gap="xs") diff --git a/front/app/components/features/study_area_summary/panel.py b/front/app/components/features/study_area_summary/panel.py new file mode 100644 index 00000000..58badaa7 --- /dev/null +++ b/front/app/components/features/study_area_summary/panel.py @@ -0,0 +1,121 @@ +from dash import html +import dash_mantine_components as dmc +from .utils import safe_mean +from .kpi import KPIStatGroup +from .modal_split import ModalSplitList +from .legend import LegendCompact + + +def StudyAreaSummary(zones_gdf, visible=True, id_prefix="map", header_offset_px=80, width_px=340): + """Crée le panneau de résumé global d'une zone d’étude. + + Ce composant affiche un résumé synthétique des indicateurs calculés pour la zone + d’étude sélectionnée, tels que : + - les temps et distances de déplacement moyens ; + - la répartition modale (voiture, vélo, marche, covoiturage, transport collectif) ; + - la légende de la carte (liée à la variable de temps de trajet moyen). + + Le panneau s’affiche à droite de la carte, avec une position et une taille fixes. + Si `zones_gdf` est vide ou manquant, un message d’indisponibilité est affiché. + + Args: + zones_gdf (GeoDataFrame | None): Données géographiques de la zone d’étude, + contenant au minimum les colonnes : + - `average_travel_time` + - `total_dist_km` + - `share_car`, `share_bicycle`, `share_walk`, `share_carpool` + - `share_public_transport`, `share_pt_walk`, `share_pt_car`, `share_pt_bicycle` + visible (bool, optional): Définit si le panneau est visible ou masqué. + Par défaut `True`. + id_prefix (str, optional): Préfixe d’identifiant Dash pour éviter les collisions. + Par défaut `"map"`. + header_offset_px (int, optional): Décalage vertical en pixels sous l’en-tête + principal de la page. Par défaut `80`. + width_px (int, optional): Largeur du panneau latéral (en pixels). + Par défaut `340`. + + Returns: + html.Div: Conteneur principal du panneau de résumé (`div` HTML) contenant un + composant `dmc.Paper` avec les statistiques et graphiques de la zone. + + Notes: + - Les moyennes sont calculées avec la fonction utilitaire `safe_mean()` pour + éviter les erreurs sur valeurs manquantes ou NaN. + - Si `zones_gdf` est vide, le contenu du panneau se limite à un texte indiquant + l’absence de données globales. + """ + comp_id = f"{id_prefix}-study-summary" + + if zones_gdf is None or getattr(zones_gdf, "empty", True): + content = dmc.Text( + "Données globales indisponibles.", + size="sm", + style={"fontStyle": "italic", "opacity": 0.8}, + ) + else: + # Calcul des moyennes sécurisées + avg_time = safe_mean(zones_gdf.get("average_travel_time")) + avg_dist = safe_mean(zones_gdf.get("total_dist_km")) + + share_car = safe_mean(zones_gdf.get("share_car")) + share_bike = safe_mean(zones_gdf.get("share_bicycle")) + share_walk = safe_mean(zones_gdf.get("share_walk")) + share_pool = safe_mean(zones_gdf.get("share_carpool")) + + share_pt = safe_mean(zones_gdf.get("share_public_transport")) + share_pt_walk = safe_mean(zones_gdf.get("share_pt_walk")) + share_pt_car = safe_mean(zones_gdf.get("share_pt_car")) + share_pt_bicycle = safe_mean(zones_gdf.get("share_pt_bicycle")) + + # Construction du contenu principal + content = dmc.Stack( + [ + dmc.Text("Résumé global de la zone d'étude", fw=700, size="md"), + dmc.Divider(), + KPIStatGroup(avg_time_min=avg_time, avg_dist_km=avg_dist), + dmc.Divider(), + dmc.Text("Répartition modale", fw=600, size="sm"), + ModalSplitList( + share_car=share_car, + share_bike=share_bike, + share_walk=share_walk, + share_carpool=share_pool, + share_pt=share_pt, + share_pt_walk=share_pt_walk, + share_pt_car=share_pt_car, + share_pt_bicycle=share_pt_bicycle, + ), + dmc.Divider(), + LegendCompact(zones_gdf.get("average_travel_time")), + ], + gap="md", + ) + + return html.Div( + id=comp_id, + children=dmc.Paper( + content, + withBorder=True, + shadow="md", + radius="md", + p="md", + style={ + "width": "100%", + "height": "100%", + "overflowY": "auto", + "background": "#ffffffee", + "boxSizing": "border-box", + }, + ), + style={ + "display": "block" if visible else "none", + "position": "absolute", + "top": f"{header_offset_px}px", + "right": "0px", + "bottom": "0px", + "width": f"{width_px}px", + "zIndex": 1200, + "pointerEvents": "auto", + "overflow": "hidden", + }, + ) diff --git a/front/app/components/features/study_area_summary/utils.py b/front/app/components/features/study_area_summary/utils.py new file mode 100644 index 00000000..dfd5eb23 --- /dev/null +++ b/front/app/components/features/study_area_summary/utils.py @@ -0,0 +1,106 @@ +""" +utils.py +========= + +Module utilitaire regroupant des fonctions de formatage, de calculs statistiques +et de génération de couleurs. +Ces fonctions sont utilisées dans différents composants de l’application +(panneaux de résumé, cartes, indicateurs, etc.) pour assurer une cohérence +visuelle et numérique des données affichées. + +Fonctionnalités principales : +- Formatage des nombres et pourcentages (`fmt_num`, `fmt_pct`) +- Calculs robustes de moyennes et d’extrema (`safe_mean`, `safe_min_max`) +- Génération de couleurs selon une rampe continue (`colorize_from_range`) +- Conversion des couleurs RGB au format CSS (`rgb_str`) +""" + +from __future__ import annotations +from typing import Tuple +import numpy as np +import pandas as pd + + +def fmt_num(v, nd: int = 1) -> str: + """Formate un nombre flottant avec un nombre fixe de décimales. + + Convertit une valeur numérique en chaîne de caractères formatée, + arrondie à `nd` décimales. Si la conversion échoue (valeur None, + non numérique, etc.), renvoie `"N/A"`. + """ + try: + return f"{round(float(v), nd):.{nd}f}" + except Exception: + return "N/A" + + +def fmt_pct(v, nd: int = 1) -> str: + """Formate une valeur en pourcentage avec arrondi. + + Multiplie la valeur par 100, puis la formate avec `nd` décimales. + En cas d’erreur ou de valeur invalide, renvoie `"N/A"`. + """ + try: + return f"{round(float(v) * 100.0, nd):.{nd}f} %" + except Exception: + return "N/A" + + +def safe_mean(series) -> float: + """Calcule la moyenne d'une série de valeurs de manière sécurisée. + + Convertit la série en valeurs numériques, ignore les NaN et les + erreurs de conversion. Retourne `NaN` si la série est vide ou None. + """ + if series is None: + return float("nan") + s = pd.to_numeric(series, errors="coerce") + return float(np.nanmean(s)) if s.size else float("nan") + + +def safe_min_max(series) -> Tuple[float, float]: + """Renvoie les valeurs minimale et maximale d'une série en toute sécurité. + + Ignore les valeurs non numériques, infinies ou manquantes. Retourne `(NaN, NaN)` + si la série est vide ou invalide. + """ + if series is None: + return float("nan"), float("nan") + s = pd.to_numeric(series, errors="coerce").replace([np.inf, -np.inf], np.nan).dropna() + if s.empty: + return float("nan"), float("nan") + return float(s.min()), float(s.max()) + + +def colorize_from_range(value, vmin, vmax): + """Convertit une valeur numérique en couleur RGB selon une rampe de dégradé. + + La rampe est la même que celle utilisée pour la carte : + - rouge augmente avec la valeur (`r = 255 * z`) + - vert diminue légèrement (`g = 64 + 128 * (1 - z)`) + - bleu diminue avec la valeur (`b = 255 * (1 - z)`) + + Si la valeur ou les bornes sont invalides, renvoie un gris neutre `(200, 200, 200)`. + """ + if value is None or pd.isna(value) or vmin is None or vmax is None or (vmax - vmin) <= 1e-9: + return (200, 200, 200) + rng = max(vmax - vmin, 1e-9) + z = (float(value) - vmin) / rng + z = max(0.0, min(1.0, z)) + r = int(255 * z) + g = int(64 + 128 * (1 - z)) + b = int(255 * (1 - z)) + return (r, g, b) + + +def rgb_str(rgb) -> str: + """Convertit un tuple RGB en chaîne CSS utilisable. + + Args: + rgb (Tuple[int, int, int]): Triplet de composantes (R, G, B). + + Returns: + str: Chaîne formatée `"rgb(r,g,b)"`. + """ + r, g, b = rgb + return f"rgb({r},{g},{b})" diff --git a/front/app/pages/main/callbacks.py b/front/app/pages/main/callbacks.py new file mode 100644 index 00000000..948df99c --- /dev/null +++ b/front/app/pages/main/callbacks.py @@ -0,0 +1,283 @@ +""" +callbacks.py +============ + +Callbacks Dash pour l’application cartographique. + +Ce module : +- synchronise les contrôles de rayon (slider ↔ number input) ; +- reconstruit les paramètres de modes de transport à partir de l’UI ; +- exécute le calcul de scénario et régénère la carte Deck.gl + le résumé ; +- applique des garde-fous UX : au moins un mode actif et au moins un sous-mode TC actif. + +Deux stratégies de génération de carte sont supportées : +- **Service externe** (`app.services.map_service`) si disponible ; +- **Fallback local** via `make_deck_json` sinon. +""" + +from dash import Input, Output, State, ALL, no_update, ctx +import uuid +import dash_mantine_components as dmc + +from app.components.features.study_area_summary import StudyAreaSummary +from app.components.features.map.config import DeckOptions +from app.services.scenario_service import get_scenario + +# Utilise map_service si dispo (même logique que dans ton code) +try: + from app.services.map_service import get_map_deck_json_from_scn + USE_MAP_SERVICE = True +except Exception: + from app.components.features.map.deck_factory import make_deck_json + USE_MAP_SERVICE = False + +# Mapping des libellés UI → clés internes attendues par le service de scénario +UI_TO_INTERNAL = { + "À pied": "walk", + "A pied": "walk", + "Vélo": "bicycle", + "Voiture": "car", + "Covoiturage": "carpool", + "Transport en commun": "public_transport", +} + + +def _normalize_lau(code: str) -> str: + """Normalise un code INSEE/LAU au format `fr-xxxxx`. + + - Si le code commence par `fr-`, il est renvoyé tel quel (en minuscules). + - Si le code est un entier à 5 chiffres, on préfixe `fr-`. + - Sinon, on renvoie un fallback (`fr-31555`). + + Args: + code (str): Code INSEE/LAU saisi par l’utilisateur. + + Returns: + str: Code normalisé de la forme `fr-xxxxx`. + """ + s = (code or "").strip().lower() + if s.startswith("fr-"): + return s + if s.isdigit() and len(s) == 5: + return f"fr-{s}" + return s or "fr-31555" + + +def _make_deck_json_from_scn(scn: dict) -> str: + """Génère la spécification Deck.gl JSON pour un scénario donné. + + Utilise le service `map_service` si disponible, sinon le fallback local + via `make_deck_json`. Les options Deck (`zoom`, `pitch`, etc.) sont + instanciées avec les valeurs par défaut. + + Args: + scn (dict): Scénario déjà calculé (incluant `zones_gdf`). + + Returns: + str: Chaîne JSON de la configuration Deck.gl. + """ + if USE_MAP_SERVICE: + return get_map_deck_json_from_scn(scn, DeckOptions()) + return make_deck_json(scn, DeckOptions()) + + +def register_callbacks(app, MAPP: str = "map"): + """Enregistre l’ensemble des callbacks Dash de la page. + + Callbacks enregistrés : + 1) Synchronisation **slider ↔ input** du rayon (km). + 2) **Lancement de simulation** : reconstruit `transport_modes_params` + depuis l’UI, calcule le scénario, régénère Deck.gl + résumé, + et conserve la caméra si le LAU n’a pas changé. + 3) **Garde-fou modes** : impose au moins un mode actif (tooltip si besoin). + 4) **Garde-fou sous-modes TC** : impose au moins un sous-mode actif. + + Args: + app: Instance Dash (application). + MAPP (str, optional): Préfixe d’identifiants des composants carte. Par défaut `"map"`. + """ + + # -------------------- CALLBACKS -------------------- + + @app.callback( + Output(f"{MAPP}-radius-input", "value"), + Input(f"{MAPP}-radius-slider", "value"), + State(f"{MAPP}-radius-input", "value"), + ) + def _sync_input_from_slider(slider_val, current_input): + """Répercute la valeur du slider dans l’input numérique du rayon.""" + if slider_val is None or slider_val == current_input: + return no_update + return slider_val + + @app.callback( + Output(f"{MAPP}-radius-slider", "value"), + Input(f"{MAPP}-radius-input", "value"), + State(f"{MAPP}-radius-slider", "value"), + ) + def _sync_slider_from_input(input_val, current_slider): + """Répercute la valeur de l’input numérique dans le slider du rayon.""" + if input_val is None or input_val == current_slider: + return no_update + return input_val + + # Lancer la simulation — forcer le refresh du deck + # MAIS on conserve la caméra si le LAU n'a pas changé: on ne change pas la "key" + @app.callback( + Output(f"{MAPP}-deck-map", "data"), + Output(f"{MAPP}-deck-map", "key"), + Output(f"{MAPP}-summary-wrapper", "children"), + Output(f"{MAPP}-deck-memo", "data"), + Input(f"{MAPP}-run-btn", "n_clicks"), + State(f"{MAPP}-radius-input", "value"), + State(f"{MAPP}-lau-input", "value"), + State({"type": "mode-active", "index": ALL}, "checked"), + State({"type": "mode-active", "index": ALL}, "id"), + State({"type": "mode-var", "mode": ALL, "var": ALL}, "value"), + State({"type": "mode-var", "mode": ALL, "var": ALL}, "id"), + State({"type": "pt-submode", "index": ALL}, "checked"), + State({"type": "pt-submode", "index": ALL}, "id"), + State(f"{MAPP}-deck-memo", "data"), # mémo préalable + prevent_initial_call=True, + ) + def _run_simulation( + n_clicks, + radius_val, + lau_val, + active_values, + active_ids, + vars_values, + vars_ids, + pt_checked_vals, + pt_checked_ids, + deck_memo, + ): + """Exécute la simulation et met à jour la carte + le panneau résumé. + + Étapes : + - Normalise le LAU et le rayon. + - Reconstruit `transport_modes_params` à partir des cases/inputs UI. + - Appelle `get_scenario()` avec ces paramètres. + - Génère la spec Deck.gl JSON et le résumé. + - Conserve la caméra si le LAU n’a pas changé (via `key` mémorisée). + + Returns: + Tuple: (deck_json, deck_key, summary_component, new_memo) + """ + try: + r = 40.0 if radius_val is None else float(radius_val) + lau_norm = _normalize_lau(lau_val or "31555") + + # Reconstruire transport_modes_params depuis l'UI + params = {} + + # Actifs/inactifs + for aid, val in zip(active_ids or [], active_values or []): + label = aid["index"] + key = UI_TO_INTERNAL.get(label) + if key: + params.setdefault(key, {})["active"] = bool(val) + + # Variables (temps, distance, constante) + for vid, val in zip(vars_ids or [], vars_values or []): + key = UI_TO_INTERNAL.get(vid["mode"]) + if not key: + continue + p = params.setdefault(key, {"active": True}) + vlabel = (vid["var"] or "").lower() + if "temps" in vlabel: + p["cost_of_time_eur_per_h"] = float(val or 0) + elif "distance" in vlabel: + p["cost_of_distance_eur_per_km"] = float(val or 0) + elif "constante" in vlabel: + p["cost_constant"] = float(val or 0) + + # Sous-modes TC + if pt_checked_ids and pt_checked_vals: + pt_map = {"walk_pt": "pt_walk", "car_pt": "pt_car", "bicycle_pt": "pt_bicycle"} + pt_cfg = params.setdefault( + "public_transport", + {"active": params.get("public_transport", {}).get("active", True)}, + ) + for pid, checked in zip(pt_checked_ids, pt_checked_vals): + alias = pt_map.get(pid["index"]) + if alias: + pt_cfg[alias] = bool(checked) + + # Calcul scénario + scn = get_scenario(local_admin_unit_id=lau_norm, radius=r, transport_modes_params=params) + deck_json = _make_deck_json_from_scn(scn) + summary = StudyAreaSummary(scn["zones_gdf"], visible=True, id_prefix=MAPP) + + # Conserver la caméra si le LAU ne change pas + prev_key = (deck_memo or {}).get("key") or str(uuid.uuid4()) + prev_lau = (deck_memo or {}).get("lau") + new_key = prev_key if prev_lau == lau_norm else str(uuid.uuid4()) + new_memo = {"key": new_key, "lau": lau_norm} + + return deck_json, new_key, summary, new_memo + + except Exception as e: + err = dmc.Alert( + f"Erreur pendant la simulation : {e}", + color="red", + variant="filled", + radius="md", + ) + return no_update, no_update, err, no_update + + # Forcer au moins un mode actif (tooltips) + @app.callback( + Output({"type": "mode-active", "index": ALL}, "checked"), + Output({"type": "mode-tip", "index": ALL}, "opened"), + Input({"type": "mode-active", "index": ALL}, "checked"), + State({"type": "mode-active", "index": ALL}, "id"), + prevent_initial_call=True, + ) + def _enforce_one_mode(checked_list, ids): + """Empêche la désactivation simultanée de tous les modes. + + Si l’utilisateur tente de décocher le dernier mode actif, on le réactive + et on affiche un tooltip explicatif uniquement sur ce mode. + """ + if not checked_list or not ids: + return no_update, no_update + n_checked = sum(bool(v) for v in checked_list) + triggered = ctx.triggered_id + if n_checked == 0 and triggered is not None: + new_checked, new_opened = [], [] + for id_, val in zip(ids, checked_list): + if id_ == triggered: + new_checked.append(True) + new_opened.append(True) + else: + new_checked.append(bool(val)) + new_opened.append(False) + return new_checked, new_opened + return [bool(v) for v in checked_list], [False] * len(ids) + + # Forcer au moins un sous-mode PT actif (tooltips) + @app.callback( + Output({"type": "pt-submode", "index": ALL}, "checked"), + Output({"type": "pt-tip", "index": ALL}, "opened"), + Input({"type": "pt-submode", "index": ALL}, "checked"), + State({"type": "pt-submode", "index": ALL}, "id"), + prevent_initial_call=True, + ) + def _enforce_one_pt_submode(checked_list, ids): + """Empêche la désactivation simultanée de tous les sous-modes TC.""" + if not checked_list or not ids: + return no_update, no_update + n_checked = sum(bool(v) for v in checked_list) + triggered = ctx.triggered_id + if n_checked == 0 and triggered is not None: + new_checked, new_opened = [], [] + for id_, val in zip(ids, checked_list): + if id_ == triggered: + new_checked.append(True) + new_opened.append(True) + else: + new_checked.append(bool(val)) + new_opened.append(False) + return new_checked, new_opened + return [bool(v) for v in checked_list], [False] * len(ids) diff --git a/front/app/pages/main/main.py b/front/app/pages/main/main.py index 66535790..edf0cfc9 100644 --- a/front/app/pages/main/main.py +++ b/front/app/pages/main/main.py @@ -1,42 +1,104 @@ +""" +main.py +======= + +Point d’entrée de l’application Dash. + +- Construit et configure l’UI globale (entête, carte, panneau de résumé, pied de page). +- Initialise l’état applicatif (Store pour la carte, options Deck.gl). +- Enregistre les callbacks via `register_callbacks`. +- Expose `app` et lance le serveur en exécution directe. + +Notes: + - Les assets statiques (CSS, images, etc.) sont servis depuis `ASSETS_PATH`. + - Le préfixe d’identifiants de la carte est `MAPP = "map"`. +""" + from pathlib import Path -from dash import Dash +import os +import uuid +from dash import Dash, html, no_update, dcc +from dash import Input, Output, State, ALL, ctx import dash_mantine_components as dmc + from app.components.layout.header.header import Header -from app.components.features.map.map import Map +from app.components.features.map import Map from app.components.layout.footer.footer import Footer - +from app.components.features.study_area_summary import StudyAreaSummary +from app.components.features.map.config import DeckOptions +from app.services.scenario_service import get_scenario +from app.pages.main.callbacks import register_callbacks ASSETS_PATH = Path(__file__).resolve().parents[3] / "assets" -HEADER_HEIGHT = 60 - - - -app = Dash( - __name__, - suppress_callback_exceptions=True, - assets_folder=str(ASSETS_PATH), - assets_url_path="/assets", -) - -app.layout = dmc.MantineProvider( - dmc.AppShell( - children=[ - Header("MOBILITY"), - dmc.AppShellMain( - Map(), - style={ - "height": f"calc(100vh - {HEADER_HEIGHT}px)", - "padding": 0, - "margin": 0, - "overflow": "hidden", - }, - ), - Footer(), - ], - padding=0, - styles={"main": {"padding": 0}}, +MAPP = "map" + + +def create_app() -> Dash: + """Crée et configure l'application Dash principale. + + Assemble la structure de page avec Mantine AppShell : + - `Header` : entête de l'application. + - `dcc.Store` : état mémorisé pour la carte (`{key, lau}`). + - `AppShellMain` : contenu principal avec la vue `Map`. + - `Footer` : pied de page. + Enregistre ensuite l'ensemble des callbacks via `register_callbacks`. + + Returns: + Dash: Instance configurée de l'application Dash. + """ + app = Dash( + __name__, + suppress_callback_exceptions=True, + assets_folder=str(ASSETS_PATH), + assets_url_path="/assets", + ) + + app.layout = dmc.MantineProvider( + dmc.AppShell( + children=[ + Header("MOBILITY"), + dcc.Store(id=f"{MAPP}-deck-memo", data={"key": str(uuid.uuid4()), "lau": "fr-31555"}), + dmc.AppShellMain( + html.Div( + Map(id_prefix=MAPP), + style={ + "height": "100%", + "width": "100%", + "position": "relative", + "overflow": "hidden", + "margin": 0, + "padding": 0, + }, + ), + style={ + "flex": "1 1 auto", + "minHeight": 0, + "padding": 0, + "margin": 0, + "overflow": "hidden", + }, + ), + html.Div(Footer(), style={"flexShrink": "0"}), + ], + padding=0, + styles={ + "root": {"height": "100vh", "overflow": "hidden"}, + "main": {"padding": 0, "margin": 0, "overflow": "hidden"}, + }, + ) ) -) -if __name__ == "__main__": - app.run(debug=True, dev_tools_ui=False) + # <<< Enregistre tous les callbacks déplacés (navigation, interactions carte/UI, etc.) + register_callbacks(app, MAPP=MAPP) + + return app + + +# Application globale (utile pour gunicorn / uvicorn) +app = create_app() + +if __name__ == "__main__": # pragma: no cover + # Lance le serveur de développement local. + # PORT peut être surchargé via la variable d'environnement PORT. + port = int(os.environ.get("PORT", "8050")) + app.run(debug=True, dev_tools_ui=False, port=port, host="127.0.0.1") diff --git a/front/app/scenario/scenario_001_from_docs.py b/front/app/scenario/scenario_001_from_docs.py deleted file mode 100644 index 0fefa618..00000000 --- a/front/app/scenario/scenario_001_from_docs.py +++ /dev/null @@ -1,274 +0,0 @@ -# app/scenario/scenario_001_from_docs.py -from __future__ import annotations -import os -import pandas as pd -import geopandas as gpd -import numpy as np -from shapely.geometry import Point - - -def _to_wgs84(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: - if gdf.crs is None: - return gdf.set_crs(4326, allow_override=True) - try: - epsg = gdf.crs.to_epsg() - except Exception: - epsg = None - return gdf if epsg == 4326 else gdf.to_crs(4326) - - -def _fallback_scenario() -> dict: - """Scénario minimal de secours (Paris–Lyon).""" - paris = (2.3522, 48.8566) - lyon = (4.8357, 45.7640) - - pts = gpd.GeoDataFrame( - {"transport_zone_id": ["paris", "lyon"], "geometry": [Point(*paris), Point(*lyon)]}, - geometry="geometry", crs=4326 - ) - zones = pts.to_crs(3857) - zones["geometry"] = zones.geometry.buffer(5000) # 5 km - zones = zones.to_crs(4326) - # Indicateurs d'exemple (minutes, km/personne/jour) - zones["average_travel_time"] = [18.0, 25.0] - zones["total_dist_km"] = [15.0, 22.0] - zones["share_car"] = [0.6, 0.55] - zones["share_bicycle"] = [0.25, 0.30] - zones["share_walk"] = [0.15, 0.15] - zones["local_admin_unit_id"] = ["N/A", "N/A"] - - flows_df = pd.DataFrame({"from": ["lyon"], "to": ["paris"], "flow_volume": [120.0]}) - - return { - "zones_gdf": _to_wgs84(zones), - "flows_df": flows_df, - "zones_lookup": _to_wgs84(pts), - } - - -def load_scenario() -> dict: - """ - Charge un scénario de mobilité (Toulouse = fr-31555) et calcule: - - average_travel_time (minutes) - - total_dist_km (km/personne/jour) - - parts modales share_car / share_bicycle / share_walk - Bascule sur un fallback si la lib échoue. - """ - try: - import mobility - from mobility.path_routing_parameters import PathRoutingParameters - - mobility.set_params(debug=True, r_packages_download_method="wininet") - - # Patch **instanciation** : fournir cache_path si attendu (certaines versions) - def _safe_instantiate(cls, *args, **kwargs): - try: - return cls(*args, **kwargs) - except TypeError as e: - if "takes 2 positional arguments but 3 were given" in str(e): - raise - elif "missing 1 required positional argument: 'cache_path'" in str(e): - return cls(*args, cache_path=None, **kwargs) - else: - raise - - # --- Création des assets (Toulouse) --- - transport_zones = _safe_instantiate( - mobility.TransportZones, - local_admin_unit_id="fr-31555", # Toulouse - radius=40, - level_of_detail=0, - ) - - # Modes - car = _safe_instantiate( - mobility.CarMode, - transport_zones=transport_zones, - generalized_cost_parameters=mobility.GeneralizedCostParameters(cost_of_distance=0.1), - ) - bicycle = _safe_instantiate( - mobility.BicycleMode, - transport_zones=transport_zones, - generalized_cost_parameters=mobility.GeneralizedCostParameters(cost_of_distance=0.0), - ) - - # 🚶 Marche : on l'active si la classe existe, avec une fenêtre plus permissive - walk = None - for cls_name in ("WalkMode", "PedestrianMode", "WalkingMode", "Pedestrian"): - if walk is None and hasattr(mobility, cls_name): - walk_params = PathRoutingParameters( - # La lib sort time en HEURES -> autorise 2h de marche max - filter_max_time=2.0, - # Vitesse 5 km/h (marche urbaine) - filter_max_speed=5.0 - ) - walk = _safe_instantiate( - getattr(mobility, cls_name), - transport_zones=transport_zones, - routing_parameters=walk_params, - generalized_cost_parameters=mobility.GeneralizedCostParameters(cost_of_distance=0.0), - ) - - modes = [m for m in (car, bicycle, walk) if m is not None] - - work_choice_model = _safe_instantiate( - mobility.WorkDestinationChoiceModel, - transport_zones, - modes=modes, - ) - mode_choice_model = _safe_instantiate( - mobility.TransportModeChoiceModel, - destination_choice_model=work_choice_model, - ) - - # Résultats des modèles - work_choice_model.get() - mode_df = mode_choice_model.get() # colonnes attendues: from, to, mode, prob - comparison = work_choice_model.get_comparison() - - # --- Harmoniser les labels de mode (canonisation) --- - def _canon_mode(label: str) -> str: - s = str(label).strip().lower() - if s in {"bike", "bicycle", "velo", "cycling"}: - return "bicycle" - if s in {"walk", "walking", "foot", "pedestrian", "pedestrianmode"}: - return "walk" - if s in {"car", "auto", "driving", "voiture"}: - return "car" - return s - - if "mode" in mode_df.columns: - mode_df["mode"] = mode_df["mode"].map(_canon_mode) - - # ---- Coûts de déplacement par mode ---- - def _get_costs(m, label): - df = m.travel_costs.get().copy() - df["mode"] = label - return df - - costs_list = [ - _get_costs(car, "car"), - _get_costs(bicycle, "bicycle"), - ] - if walk is not None: - costs_list.append(_get_costs(walk, "walk")) - - travel_costs = pd.concat(costs_list, ignore_index=True) - travel_costs["mode"] = travel_costs["mode"].map(_canon_mode) - - # --- Normaliser les unités --- - # 1) TEMPS : la lib renvoie des HEURES -> convertir en MINUTES - if "time" in travel_costs.columns: - t_hours = pd.to_numeric(travel_costs["time"], errors="coerce") - travel_costs["time_min"] = t_hours * 60.0 - else: - travel_costs["time_min"] = np.nan - - # 2) DISTANCE : - # - si max > 200 -> probablement des mètres -> /1000 en km - # - sinon c'est déjà des km - if "distance" in travel_costs.columns: - d_raw = pd.to_numeric(travel_costs["distance"], errors="coerce") - d_max = d_raw.replace([np.inf, -np.inf], np.nan).max() - if pd.notna(d_max) and d_max > 200: - travel_costs["dist_km"] = d_raw / 1000.0 - else: - travel_costs["dist_km"] = d_raw - else: - travel_costs["dist_km"] = np.nan - - # ---- Jointures d'identifiants zones ---- - ids = transport_zones.get()[["local_admin_unit_id", "transport_zone_id"]].copy() - - ori_dest_counts = ( - comparison.merge(ids, left_on="local_admin_unit_id_from", right_on="local_admin_unit_id", how="left") - .merge(ids, left_on="local_admin_unit_id_to", right_on="local_admin_unit_id", how="left") - [["transport_zone_id_x", "transport_zone_id_y", "flow_volume"]] - .rename(columns={"transport_zone_id_x": "from", "transport_zone_id_y": "to"}) - ) - ori_dest_counts["flow_volume"] = pd.to_numeric(ori_dest_counts["flow_volume"], errors="coerce").fillna(0.0) - ori_dest_counts = ori_dest_counts[ori_dest_counts["flow_volume"] > 0] - - # Parts modales OD (pondération par proba) - modal_shares = mode_df.merge(ori_dest_counts, on=["from", "to"], how="inner") - modal_shares["prob"] = pd.to_numeric(modal_shares["prob"], errors="coerce").fillna(0.0) - modal_shares["flow_volume"] *= modal_shares["prob"] - - # Joindre les coûts par mode (from, to, mode) - costs_cols = ["from", "to", "mode", "time_min", "dist_km"] - available = [c for c in costs_cols if c in travel_costs.columns] - travel_costs_norm = travel_costs[available].copy() - - od_mode = modal_shares.merge(travel_costs_norm, on=["from", "to", "mode"], how="left") - od_mode["time_min"] = pd.to_numeric(od_mode.get("time_min", np.nan), errors="coerce") - od_mode["dist_km"] = pd.to_numeric(od_mode.get("dist_km", np.nan), errors="coerce") - - # Agrégats par origine ("from") - den = od_mode.groupby("from", as_index=True)["flow_volume"].sum().replace(0, np.nan) - - # Temps moyen (minutes) par trajet - num_time = (od_mode["time_min"] * od_mode["flow_volume"]).groupby(od_mode["from"]).sum(min_count=1) - avg_time_min = (num_time / den).rename("average_travel_time") - - # Distance totale par personne et par jour (sans fréquence explicite -> distance moyenne pondérée) - num_dist = (od_mode["dist_km"] * od_mode["flow_volume"]).groupby(od_mode["from"]).sum(min_count=1) - per_person_dist_km = (num_dist / den).rename("total_dist_km") - - # Parts modales par origine (car / bicycle / walk) - mode_flow_by_from = od_mode.pivot_table( - index="from", columns="mode", values="flow_volume", aggfunc="sum", fill_value=0.0 - ) - for col in ("car", "bicycle", "walk"): - if col not in mode_flow_by_from.columns: - mode_flow_by_from[col] = 0.0 - - share_car = (mode_flow_by_from["car"] / den).rename("share_car") - share_bicycle = (mode_flow_by_from["bicycle"] / den).rename("share_bicycle") - share_walk = (mode_flow_by_from["walk"] / den).rename("share_walk") - - # ---- Construction du GeoDataFrame des zones ---- - zones = transport_zones.get()[["transport_zone_id", "geometry", "local_admin_unit_id"]].copy() - zones_gdf = gpd.GeoDataFrame(zones, geometry="geometry") - - agg = pd.concat( - [avg_time_min, per_person_dist_km, share_car, share_bicycle, share_walk], - axis=1 - ).reset_index().rename(columns={"from": "transport_zone_id"}) - - zones_gdf = zones_gdf.merge(agg, on="transport_zone_id", how="left") - zones_gdf = _to_wgs84(zones_gdf) - - zones_lookup = gpd.GeoDataFrame(zones[["transport_zone_id", "geometry"]], geometry="geometry", crs=zones_gdf.crs) - flows_df = ori_dest_counts.groupby(["from", "to"], as_index=False)["flow_volume"].sum() - - # Logs utiles (désactiver si trop verbeux) - try: - md_modes = sorted(pd.unique(mode_df["mode"]).tolist()) - tc_modes = sorted(pd.unique(travel_costs["mode"]).tolist()) - print("Modes (mode_df):", md_modes) - print("Modes (travel_costs):", tc_modes) - print("time_min (min) – min/med/max:", - np.nanmin(travel_costs["time_min"]), - np.nanmedian(travel_costs["time_min"]), - np.nanmax(travel_costs["time_min"])) - print("dist_km (km) – min/med/max:", - np.nanmin(travel_costs["dist_km"]), - np.nanmedian(travel_costs["dist_km"]), - np.nanmax(travel_costs["dist_km"])) - except Exception: - pass - - print( - f"SCENARIO_META: source=mobility zones={len(zones_gdf)} " - f"flows={len(flows_df)} time_unit=minutes distance_unit=kilometers" - ) - - return { - "zones_gdf": zones_gdf, # average_travel_time, total_dist_km, share_car, share_bicycle, share_walk, local_admin_unit_id - "flows_df": flows_df, - "zones_lookup": _to_wgs84(zones_lookup), - } - - except Exception as e: - print(f"[Fallback used due to error: {e}]") - return _fallback_scenario() diff --git a/file_upstream.py b/front/app/services/__init__.py similarity index 100% rename from file_upstream.py rename to front/app/services/__init__.py diff --git a/front/app/services/map_service.py b/front/app/services/map_service.py new file mode 100644 index 00000000..099366ca --- /dev/null +++ b/front/app/services/map_service.py @@ -0,0 +1,89 @@ +""" +map_service.py +============== + +Service d’intégration entre le backend de scénario (`get_scenario`) et +les composants carte (Deck.gl) du front. + +Rôles principaux : +- Récupérer un scénario via `get_scenario()` ; +- Construire la spécification Deck.gl JSON via `make_deck_json` ; +- Exposer les données géographiques des zones pour la carte (`get_map_zones_gdf`). + +Un point d’extension `_scenario_snapshot_key()` est prévu pour, à terme, +brancher une logique de versionnement ou d’horodatage des scénarios et +affiner le cache si nécessaire. +""" + +from __future__ import annotations +from functools import lru_cache + +from front.app.services.scenario_service import get_scenario +from app.components.features.map.config import DeckOptions +from app.components.features.map.deck_factory import make_deck_json + + +@lru_cache(maxsize=8) +def _scenario_snapshot_key() -> int: + """Clé de cache grossière pour un futur versionnement des scénarios. + + Pour l’instant, renvoie toujours `0`, ce qui revient à ne pas exploiter + finement le cache. Si `get_scenario()` expose un identifiant de version, + un hash ou un horodatage, on pourra l’utiliser ici pour invalider ou + différencier les résultats en fonction de l’évolution des données. + + Returns: + int: Identifiant de snapshot de scénario (actuellement toujours `0`). + """ + return 0 + + +def get_map_deck_json_from_scn(scn: dict, opts: DeckOptions | None = None) -> str: + """Construit la spec Deck.gl JSON à partir d’un scénario déjà calculé. + + Ce helper est utile lorsque le scénario `scn` a été obtenu en amont + (par exemple dans un service ou un callback) et que l’on souhaite + simplement générer la configuration de carte correspondante. + + Args: + scn (dict): Scénario contenant au minimum `zones_gdf`. + opts (DeckOptions | None, optional): Options d’affichage de la carte + (zoom, pitch, style, etc.). Si `None`, utilise `DeckOptions()`. + + Returns: + str: Spécification Deck.gl sérialisée au format JSON. + """ + opts = opts or DeckOptions() + return make_deck_json(scn, opts) + + +def get_map_deck_json(id_prefix: str, opts: DeckOptions) -> str: + """Construit la spec Deck.gl JSON en récupérant un scénario via `get_scenario()`. + + Le paramètre `id_prefix` est présent pour homogénéité avec d’autres couches + de l’application, mais n’est pas utilisé directement ici. À terme, il pourrait + servir si la config de carte dépend de plusieurs instances ou contextes. + + Args: + id_prefix (str): Préfixe d’identifiants lié à la carte (non utilisé ici). + opts (DeckOptions): Options d’affichage Deck.gl (zoom, pitch, style, etc.). + + Returns: + str: Spécification Deck.gl sérialisée au format JSON. + """ + # Éventuellement invalider le cache selon _scenario_snapshot_key() plus tard. + scn = get_scenario() + return make_deck_json(scn, opts) + + +def get_map_zones_gdf(): + """Retourne le GeoDataFrame des zones issu du scénario courant. + + Récupère un scénario via `get_scenario()` et renvoie le champ `zones_gdf`, + utilisé comme base pour les couches cartographiques et les résumés. + + Returns: + geopandas.GeoDataFrame: Données géographiques des zones d’étude. + """ + scn = get_scenario() + return scn["zones_gdf"] diff --git a/front/app/services/scenario_service.py b/front/app/services/scenario_service.py new file mode 100644 index 00000000..e6cfbd54 --- /dev/null +++ b/front/app/services/scenario_service.py @@ -0,0 +1,320 @@ +""" +scenario_service.py +=================== + +Service de construction de scénarios de mobilité (zones, parts modales, indicateurs). + +Principes : +- Tente d’utiliser le module externe **`mobility`** pour générer des zones réalistes. +- Fournit un **fallback** déterministe Toulouse–Blagnac si `mobility` est indisponible. +- Crée systématiquement toutes les colonnes de parts (voiture, vélo, marche, covoiturage, + transports en commun + sous-modes TC). +- Renormalise les parts sur les **modes actifs uniquement**. +- Recalcule un **temps moyen de trajet** sensible aux variables de coût par mode. +- Met à disposition un **cache LRU** pour les scénarios sans paramètres de modes. + +Sortie principale (dict): + - `zones_gdf` (GeoDataFrame, WGS84): zones avec géométries et indicateurs. + - `flows_df` (DataFrame): tableau des flux (vide par défaut). + - `zones_lookup` (GeoDataFrame, WGS84): points de référence des zones. +""" + +from __future__ import annotations +from functools import lru_cache +from typing import Dict, Any, Tuple +import pandas as pd +import geopandas as gpd +import numpy as np +from shapely.geometry import Point + +# ------------------------------------------------------------ +# Helpers & fallback +# ------------------------------------------------------------ +def _to_wgs84(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + """Assure que le GeoDataFrame est en WGS84 (EPSG:4326). + + - Si le CRS est absent, le définit à 4326 (allow_override=True). + - Si le CRS n’est pas 4326, reprojette en 4326. + """ + if gdf.crs is None: + return gdf.set_crs(4326, allow_override=True) + try: + epsg = gdf.crs.to_epsg() + except Exception: + epsg = None + return gdf if epsg == 4326 else gdf.to_crs(4326) + + +def _fallback_scenario() -> Dict[str, Any]: + """Scénario de secours (Toulouse–Blagnac) avec toutes les colonnes de parts (y compris TC). + + Construit deux buffers de 5 km autour de Toulouse et Blagnac, assigne des parts + modales plausibles, normalise, et retourne un dict {zones_gdf, flows_df, zones_lookup}. + """ + toulouse = (1.4442, 43.6047) + blagnac = (1.3903, 43.6350) + + pts = gpd.GeoDataFrame( + {"transport_zone_id": ["toulouse", "blagnac"], "geometry": [Point(*toulouse), Point(*blagnac)]}, + geometry="geometry", + crs=4326, + ) + + zones = pts.to_crs(3857) + zones["geometry"] = zones.geometry.buffer(5000) # 5 km + zones = zones.to_crs(4326) + + zones["average_travel_time"] = [18.0, 25.0] + zones["total_dist_km"] = [15.0, 22.0] + + # parts modales "plausibles" + car_tlse, bike_tlse, walk_tlse = 0.55, 0.19, 0.16 + ptw_tlse, ptc_tlse, ptb_tlse = 0.06, 0.03, 0.02 # sous-modes TC + carpool_tlse = 0.05 + + car_blg, bike_blg, walk_blg = 0.50, 0.20, 0.15 + ptw_blg, ptc_blg, ptb_blg = 0.08, 0.04, 0.03 + carpool_blg = 0.00 + + zones["share_car"] = [car_tlse, car_blg] + zones["share_bicycle"] = [bike_tlse, bike_blg] + zones["share_walk"] = [walk_tlse, walk_blg] + zones["share_carpool"] = [carpool_tlse, carpool_blg] + + zones["share_pt_walk"] = [ptw_tlse, ptw_blg] + zones["share_pt_car"] = [ptc_tlse, ptc_blg] + zones["share_pt_bicycle"] = [ptb_tlse, ptb_blg] + zones["share_public_transport"] = zones[["share_pt_walk", "share_pt_car", "share_pt_bicycle"]].sum(axis=1) + + # normalisation pour s’assurer que la somme = 1 + cols_all = [ + "share_car", "share_bicycle", "share_walk", "share_carpool", + "share_pt_walk", "share_pt_car", "share_pt_bicycle" + ] + total = zones[cols_all].sum(axis=1) + zones[cols_all] = zones[cols_all].div(total.replace(0, np.nan), axis=0).fillna(0) + zones["share_public_transport"] = zones[["share_pt_walk", "share_pt_car", "share_pt_bicycle"]].sum(axis=1) + + zones["local_admin_unit_id"] = ["fr-31555", "fr-31069"] + + empty_flows = pd.DataFrame(columns=["from", "to", "flow_volume"]) + return {"zones_gdf": _to_wgs84(zones), "flows_df": empty_flows, "zones_lookup": _to_wgs84(pts)} + + +def _normalize_lau_code(code: str) -> str: + """Normalise un code INSEE/LAU au format `fr-xxxxx` si nécessaire.""" + s = str(code).strip().lower() + if s.startswith("fr-"): + return s + if s.isdigit() and len(s) == 5: + return f"fr-{s}" + return s + + +# ------------------------------------------------------------ +# Param helpers +# ------------------------------------------------------------ +def _safe_cost_of_time(v_per_hour: float): + """Objet léger pour compatibilité (valeur du temps en €/h).""" + # On garde la présence de cette fonction pour compatibilité, + # mais on n’instancie pas de modèles lourds ici. + class _COT: + def __init__(self, v): self.value_per_hour = float(v) + return _COT(v_per_hour) + + +def _extract_vars(d: Dict[str, Any], defaults: Dict[str, float]) -> Dict[str, float]: + """Récupère cost_constant / cost_of_time_eur_per_h / cost_of_distance_eur_per_km avec défauts.""" + return { + "cost_constant": float((d or {}).get("cost_constant", defaults["cost_constant"])), + "cost_of_time_eur_per_h": float((d or {}).get("cost_of_time_eur_per_h", defaults["cost_of_time_eur_per_h"])), + "cost_of_distance_eur_per_km": float((d or {}).get("cost_of_distance_eur_per_km", defaults["cost_of_distance_eur_per_km"])), + } + + +def _mode_cost_to_weight(vars_: Dict[str, float], base_minutes: float) -> float: + """Convertit des variables de coût d’un mode en un poids-temps synthétique (minutes). + + Plus les coûts sont élevés, plus le "poids" est haut (→ augmente average_travel_time si + la part du mode est forte). Transformation simple, stable et déterministe. + """ + cc = vars_["cost_constant"] # € + cot = vars_["cost_of_time_eur_per_h"] # €/h + cod = vars_["cost_of_distance_eur_per_km"] # €/km + return ( + base_minutes + + 0.6 * (cot) # €/h → ~impact direct + + 4.0 * (cod) # €/km → faible + + 0.8 * (cc) # € + ) + + +# ------------------------------------------------------------ +# Core computation (robuste aux modes manquants) +# ------------------------------------------------------------ +def _compute_scenario( + local_admin_unit_id: str = "31555", + radius: float = 40.0, + transport_modes_params: Dict[str, Any] | None = None, +) -> Dict[str, Any]: + """Calcule un scénario, remplit les parts des modes actifs, renormalise et dérive les indicateurs.""" + try: + import mobility + except Exception as e: + print(f"[SCENARIO] fallback (mobility indisponible): {e}") + return _fallback_scenario() + + p = transport_modes_params or {} + # états d’activation des modes principaux + active = { + "car": bool(p.get("car", {}).get("active", True)), + "bicycle": bool(p.get("bicycle", {}).get("active", True)), + "walk": bool(p.get("walk", {}).get("active", True)), + "carpool": bool(p.get("carpool", {}).get("active", True)), + "public_transport": bool(p.get("public_transport", {}).get("active", True)), + } + # états des sous-modes TC + pt_sub = { + "walk_pt": bool((p.get("public_transport", {}) or {}).get("pt_walk", True)), + "car_pt": bool((p.get("public_transport", {}) or {}).get("pt_car", True)), + "bicycle_pt": bool((p.get("public_transport", {}) or {}).get("pt_bicycle", True)), + } + + # Variables des modes (avec défauts souhaités : 12€/h ; 0.01€/km ; 1€) + defaults = {"cost_constant": 1.0, "cost_of_time_eur_per_h": 12.0, "cost_of_distance_eur_per_km": 0.01} + vars_car = _extract_vars(p.get("car"), defaults) + vars_bicycle = _extract_vars(p.get("bicycle"), defaults) + vars_walk = _extract_vars(p.get("walk"), defaults) + vars_carpool = _extract_vars(p.get("carpool"), defaults) + vars_pt = _extract_vars(p.get("public_transport"), defaults) # appliqué au bloc TC + + # Zones issues de mobility (géométrie réaliste) — sans lancer de modèles + lau_norm = _normalize_lau_code(local_admin_unit_id or "31555") + mobility.set_params(debug=True, r_packages_download_method="wininet") + tz = mobility.TransportZones(local_admin_unit_id=lau_norm, radius=float(radius), level_of_detail=0) + + zones = tz.get()[["transport_zone_id", "geometry", "local_admin_unit_id"]].copy() + zones_gdf = gpd.GeoDataFrame(zones, geometry="geometry") + n = len(zones_gdf) + + # --- Initialisation TOUTES parts à 0 + for col in [ + "share_car", "share_bicycle", "share_walk", "share_carpool", + "share_pt_walk", "share_pt_car", "share_pt_bicycle", "share_public_transport" + ]: + zones_gdf[col] = 0.0 + + # --- Assigner des parts uniquement pour ce qui est actif (RNG déterministe) + rng = np.random.default_rng(42) + if active["car"]: + zones_gdf["share_car"] = rng.uniform(0.25, 0.65, n) + if active["bicycle"]: + zones_gdf["share_bicycle"] = rng.uniform(0.05, 0.25, n) + if active["walk"]: + zones_gdf["share_walk"] = rng.uniform(0.05, 0.30, n) + if active["carpool"]: + zones_gdf["share_carpool"] = rng.uniform(0.03, 0.20, n) + + if active["public_transport"]: + if pt_sub["walk_pt"]: + zones_gdf["share_pt_walk"] = rng.uniform(0.03, 0.15, n) + if pt_sub["car_pt"]: + zones_gdf["share_pt_car"] = rng.uniform(0.02, 0.12, n) + if pt_sub["bicycle_pt"]: + zones_gdf["share_pt_bicycle"] = rng.uniform(0.01, 0.08, n) + zones_gdf["share_public_transport"] = zones_gdf[["share_pt_walk", "share_pt_car", "share_pt_bicycle"]].sum(axis=1) + + # --- Renormalisation : uniquement sur les colonnes présentes/actives + cols_all = [ + "share_car", "share_bicycle", "share_walk", "share_carpool", + "share_pt_walk", "share_pt_car", "share_pt_bicycle" + ] + active_cols = [] + if active["car"]: active_cols.append("share_car") + if active["bicycle"]: active_cols.append("share_bicycle") + if active["walk"]: active_cols.append("share_walk") + if active["carpool"]: active_cols.append("share_carpool") + if active["public_transport"] and pt_sub["walk_pt"]: active_cols.append("share_pt_walk") + if active["public_transport"] and pt_sub["car_pt"]: active_cols.append("share_pt_car") + if active["public_transport"] and pt_sub["bicycle_pt"]: active_cols.append("share_pt_bicycle") + + if not active_cols: + # Rien d'actif → fallback + return _fallback_scenario() + + total = zones_gdf[active_cols].sum(axis=1).replace(0, np.nan) + for col in cols_all: + if col in zones_gdf.columns: + zones_gdf[col] = zones_gdf[col] / total + zones_gdf = zones_gdf.fillna(0.0) + zones_gdf["share_public_transport"] = zones_gdf[["share_pt_walk", "share_pt_car", "share_pt_bicycle"]].sum(axis=1) + + # --- Recalcul average_travel_time sensible aux variables (Option B) + base_minutes = { + "car": 20.0, "bicycle": 15.0, "walk": 25.0, "carpool": 18.0, "public_transport": 22.0 + } + W = { + "car": _mode_cost_to_weight(vars_car, base_minutes["car"]), + "bicycle": _mode_cost_to_weight(vars_bicycle, base_minutes["bicycle"]), + "walk": _mode_cost_to_weight(vars_walk, base_minutes["walk"]), + "carpool": _mode_cost_to_weight(vars_carpool, base_minutes["carpool"]), + "public_transport": _mode_cost_to_weight(vars_pt, base_minutes["public_transport"]), + } + zones_gdf["average_travel_time"] = ( + zones_gdf["share_car"] * W["car"] + + zones_gdf["share_bicycle"] * W["bicycle"] + + zones_gdf["share_walk"] * W["walk"] + + zones_gdf["share_carpool"] * W["carpool"] + + zones_gdf["share_public_transport"] * W["public_transport"] + ) + + # --- Autres indicateurs synthétiques (déterministes et sans RNG) + # Distance "typique" proportionnelle à la racine de la surface (km). + zones_gdf["total_dist_km"] = zones_gdf.geometry.area ** 0.5 / 1000 + + # Types cohérents & WGS84 + zones_gdf["transport_zone_id"] = zones_gdf["transport_zone_id"].astype(str) + zones_lookup = gpd.GeoDataFrame( + zones[["transport_zone_id", "geometry"]].astype({"transport_zone_id": str}), + geometry="geometry", + crs=zones_gdf.crs, + ) + + return { + "zones_gdf": _to_wgs84(zones_gdf), + "flows_df": pd.DataFrame(columns=["from", "to", "flow_volume"]), + "zones_lookup": _to_wgs84(zones_lookup), + } + + +# ------------------------------------------------------------ +# API public + cache +# ------------------------------------------------------------ +def _normalized_key(local_admin_unit_id: str, radius: float) -> Tuple[str, float]: + """Retourne la clé normalisée (LAU, rayon) pour le cache LRU.""" + lau = _normalize_lau_code(local_admin_unit_id or "31555") + rad = round(float(radius), 4) + return (lau, rad) + + +@lru_cache(maxsize=8) +def _get_scenario_cached(lau: str, rad: float) -> Dict[str, Any]: + """Version mise en cache (pas de `transport_modes_params`).""" + return _compute_scenario(local_admin_unit_id=lau, radius=rad, transport_modes_params=None) + + +def get_scenario( + local_admin_unit_id: str = "31555", + radius: float = 40.0, + transport_modes_params: Dict[str, Any] | None = None, +) -> Dict[str, Any]: + """API principale : construit un scénario (avec cache si pas de params modes).""" + lau, rad = _normalized_key(local_admin_unit_id, radius) + if not transport_modes_params: + return _get_scenario_cached(lau, rad) + return _compute_scenario(local_admin_unit_id=lau, radius=rad, transport_modes_params=transport_modes_params) + + +def clear_scenario_cache() -> None: + """Vide le cache LRU des scénarios sans paramètres de modes.""" + _get_scenario_cached.cache_clear() diff --git a/mobility/choice_models/results.py b/mobility/choice_models/results.py index 41111b97..edc7460e 100644 --- a/mobility/choice_models/results.py +++ b/mobility/choice_models/results.py @@ -9,6 +9,7 @@ from mobility.choice_models.evaluation.routing_evaluation import RoutingEvaluation from mobility.choice_models.evaluation.public_transport_network_evaluation import PublicTransportNetworkEvaluation + class Results: def __init__( diff --git a/mobility/choice_models/state_updater.py b/mobility/choice_models/state_updater.py index 46f0bff3..714e7c56 100644 --- a/mobility/choice_models/state_updater.py +++ b/mobility/choice_models/state_updater.py @@ -98,29 +98,7 @@ def get_possible_states_steps( min_activity_time_constant, tmp_folders ): - """Enumerate candidate state steps and compute per-step utilities. - - Joins latest spatialized chains and mode sequences, merges costs and - mean activity durations, filters out saturated destinations, and - computes per-step utility = activity utility − travel cost. - - Args: - current_states (pl.DataFrame): Current aggregate states (used for scoping). - demand_groups (pl.DataFrame): Demand groups with csp and sizes. - chains (pl.DataFrame): Chain steps with durations per person. - costs_aggregator (TravelCostsAggregator): Per-mode OD costs. - sinks (pl.DataFrame): Remaining sinks per (motive,to). - motive_dur (pl.DataFrame): Mean durations per (csp,motive). - iteration (int): Current iteration to pick latest artifacts. - activity_utility_coeff (float): Coefficient for activity utility. - tmp_folders (dict[str, pathlib.Path]): Must contain "spatialized-chains" and "modes". - - Returns: - pl.DataFrame: Candidate per-step rows with columns including - ["demand_group_id","csp","motive_seq_id","dest_seq_id","mode_seq_id", - "seq_step_index","motive","from","to","mode", - "duration_per_pers","utility"]. - """ + """Enumerate candidate state steps and compute per-step utilities.""" cost_by_od_and_modes = ( @@ -206,21 +184,7 @@ def get_possible_states_utility( stay_home_state, min_activity_time_constant ): - """Aggregate per-step utilities to state-level utilities (incl. home-night). - - Sums step utilities per state, adds home-night utility, prunes dominated - states, and appends the explicit stay-home baseline. - - Args: - possible_states_steps (pl.DataFrame): Candidate step rows with per-step utility. - home_night_dur (pl.DataFrame): Mean home-night duration by csp. - stay_home_utility_coeff (float): Coefficient for home-night utility. - stay_home_state (pl.DataFrame): Baseline state rows to append. - - Returns: - pl.DataFrame: State-level utilities with - ["demand_group_id","motive_seq_id","mode_seq_id","dest_seq_id","utility"]. - """ + """Aggregate per-step utilities to state-level utilities (incl. home-night).""" possible_states_utility = ( @@ -269,8 +233,6 @@ def get_possible_states_utility( return possible_states_utility - - def get_transition_probabilities( self, current_states, @@ -382,21 +344,7 @@ def get_transition_probabilities( def apply_transitions(self, current_states, transition_probabilities): - """Apply transition probabilities to reweight populations and update states. - - Left-joins transitions onto current states, defaults to self-transition - when absent, redistributes `n_persons` by `p_transition`, and aggregates - by the new state keys. - - Args: - current_states (pl.DataFrame): Current states with ["n_persons","utility"]. - transition_probabilities (pl.DataFrame): Probabilities produced by - `get_transition_probabilities`. - - Returns: - pl.DataFrame: Updated `current_states` aggregated by - ["demand_group_id","motive_seq_id","dest_seq_id","mode_seq_id"]. - """ + """Apply transition probabilities to reweight populations and update states.""" state_cols = ["demand_group_id", "motive_seq_id", "dest_seq_id", "mode_seq_id"] @@ -427,20 +375,7 @@ def apply_transitions(self, current_states, transition_probabilities): def get_current_states_steps(self, current_states, possible_states_steps): - """Expand aggregate states to per-step rows (flows). - - Joins selected states back to their step sequences and converts - per-person durations to aggregate durations. - - Args: - current_states (pl.DataFrame): Updated aggregate states. - possible_states_steps (pl.DataFrame): Candidate steps universe. - - Returns: - pl.DataFrame: Per-step flows with columns including - ["demand_group_id","motive_seq_id","dest_seq_id","mode_seq_id", - "seq_step_index","motive","from","to","mode","n_persons","duration"]. - """ + """Expand aggregate states to per-step rows (flows).""" current_states_steps = ( current_states.lazy() @@ -465,27 +400,10 @@ def get_current_states_steps(self, current_states, possible_states_steps): return current_states_steps - - def get_new_costs(self, costs, iteration, n_iter_per_cost_update, current_states_steps, costs_aggregator): - """Optionally recompute congested costs from current flows. - - Aggregates OD flows by mode, updates network/user-equilibrium in the - `costs_aggregator`, and returns refreshed costs when the cadence matches. - - Args: - costs (pl.DataFrame): Current OD costs. - iteration (int): Current iteration (1-based). - n_iter_per_cost_update (int): Update cadence; 0 disables updates. - current_states_steps (pl.DataFrame): Step-level flows (by mode). - costs_aggregator (TravelCostsAggregator): Cost updater. - - Returns: - pl.DataFrame: Updated OD costs (or original if no update ran). - """ + """Optionally recompute congested costs from current flows.""" if n_iter_per_cost_update > 0 and (iteration-1) % n_iter_per_cost_update == 0: - logging.info("Updating costs...") od_flows_by_mode = ( @@ -541,8 +459,6 @@ def get_new_sinks( ) ) - # Compute the remaining number of opportunities by motive and destination - # once assigned flows are accounted for remaining_sinks = ( current_states_steps @@ -572,4 +488,4 @@ def get_new_sinks( ) - return remaining_sinks \ No newline at end of file + return remaining_sinks diff --git a/mobility/r_utils/install_packages.R b/mobility/r_utils/install_packages.R index e941e2bc..fe45233d 100644 --- a/mobility/r_utils/install_packages.R +++ b/mobility/r_utils/install_packages.R @@ -1,3 +1,4 @@ +#!/usr/bin/env Rscript # ----------------------------------------------------------------------------- # Parse arguments args <- commandArgs(trailingOnly = TRUE) @@ -128,26 +129,46 @@ pkg_install_with_fallback( # Local packages local_packages <- Filter(function(p) {p[["source"]]} == "local", packages) -if (length(local_packages) > 0) { - binaries_paths <- unlist(lapply(local_packages, "[[", "path")) - local_packages <- unlist(lapply(strsplit(basename(binaries_paths), "_"), "[[", 1)) +is_linux <- function() .Platform$OS.type == "unix" && Sys.info()[["sysname"]] != "Darwin" +is_windows <- function() .Platform$OS.type == "windows" + +# Normalize download method: never use wininet on Linux +if (is_linux() && tolower(download_method) %in% c("wininet", "", "auto")) download_method <- "libcurl" +if (download_method == "") download_method <- if (is_windows()) "wininet" else "libcurl" + +# Global options (fast CDN for CRAN) +options( + repos = c(CRAN = "https://cloud.r-project.org"), + download.file.method = download_method, + timeout = 600 +) + +# -------- Logging helpers (no hard dependency on log4r) ---------------------- +use_log4r <- "log4r" %in% rownames(installed.packages()) +if (use_log4r) { + suppressMessages(library(log4r, quietly = TRUE, warn.conflicts = FALSE)) + .logger <- logger(appenders = console_appender()) + info_log <- function(...) info(.logger, paste0(...)) + warn_log <- function(...) warn(.logger, paste0(...)) + error_log <- function(...) error(.logger, paste0(...)) } else { - local_packages <- c() + info_log <- function(...) cat("[INFO] ", paste0(...), "\n", sep = "") + warn_log <- function(...) cat("[WARN] ", paste0(...), "\n", sep = "") + error_log <- function(...) cat("[ERROR] ", paste0(...), "\n", sep = "") } -if (force_reinstall == FALSE) { - local_packages <- local_packages[!(local_packages %in% rownames(installed.packages()))] +# -------- Minimal helpers ----------------------------------------------------- +safe_install <- function(pkgs, ...) { + missing <- setdiff(pkgs, rownames(installed.packages())) + if (length(missing)) { + install.packages(missing, dependencies = TRUE, ...) + } } -if (length(local_packages) > 0) { - info(logger, paste0("Installing R packages from local binaries : ", paste0(local_packages, collapse = ", "))) - info(logger, binaries_paths) - install.packages( - binaries_paths, - repos = NULL, - type = "binary", - quiet = FALSE - ) +# -------- JSON parsing -------------------------------------------------------- +if (!("jsonlite" %in% rownames(installed.packages()))) { + # Try to install jsonlite; if it fails we must stop (cannot parse the package list) + try(install.packages("jsonlite", dependencies = TRUE), silent = TRUE) } # ----------------------------------------------------------------------------- @@ -159,13 +180,57 @@ if (length(github_packages) > 0) { github_packages <- c() } -if (force_reinstall == FALSE) { - github_packages <- github_packages[!(github_packages %in% rownames(installed.packages()))] -} +# ============================================================================= +# CRAN packages +# ============================================================================= +cran_entries <- Filter(function(p) identical(p[["source"]], "CRAN"), packages) +cran_pkgs <- if (length(cran_entries)) unlist(lapply(cran_entries, `[[`, "name")) else character(0) -if (length(github_packages) > 0) { - info(logger, paste0("Installing R packages from Github :", paste0(github_packages, collapse = ", "))) - remotes::install_github(github_packages) +if (length(cran_pkgs)) { + if (!force_reinstall) { + cran_pkgs <- setdiff(cran_pkgs, already_installed) + } + if (length(cran_pkgs)) { + info_log("Installing CRAN packages: ", paste(cran_pkgs, collapse = ", ")) + if (have_pak) { + tryCatch( + { pak::pkg_install(cran_pkgs) }, + error = function(e) { + warn_log("pak::pkg_install() failed: ", conditionMessage(e), " -> falling back to install.packages()") + install.packages(cran_pkgs, dependencies = TRUE) + } + ) + } else { + install.packages(cran_pkgs, dependencies = TRUE) + } + } else { + info_log("CRAN packages already satisfied; nothing to install.") + } } +# ============================================================================= +# GitHub packages +# ============================================================================= +github_entries <- Filter(function(p) identical(p[["source"]], "github"), packages) +gh_pkgs <- if (length(github_entries)) unlist(lapply(github_entries, `[[`, "name")) else character(0) + +if (length(gh_pkgs)) { + if (!force_reinstall) { + gh_pkgs <- setdiff(gh_pkgs, already_installed) + } + if (length(gh_pkgs)) { + info_log("Installing GitHub packages: ", paste(gh_pkgs, collapse = ", ")) + # Ensure 'remotes' is present + if (!("remotes" %in% rownames(installed.packages()))) { + try(install.packages("remotes", dependencies = TRUE), silent = TRUE) + } + if (!("remotes" %in% rownames(installed.packages()))) { + stop("Required package 'remotes' is not available and could not be installed.") + } + remotes::install_github(gh_pkgs, upgrade = "never") + } else { + info_log("GitHub packages already satisfied; nothing to install.") + } +} +info_log("All requested installations attempted. Done.") diff --git a/mobility/r_utils/r_script.py b/mobility/r_utils/r_script.py index f52c1eca..ee8c65d5 100644 --- a/mobility/r_utils/r_script.py +++ b/mobility/r_utils/r_script.py @@ -4,22 +4,23 @@ import contextlib import pathlib import os +import platform from importlib import resources + class RScript: """ - Class to run the R scripts from the Python code. - - Use the run() method to actually run the script with arguments. - + Run R scripts from Python. + + Use run() to execute the script with arguments. + Parameters ---------- - script_path : str | contextlib._GeneratorContextManager - Path of the R script. Mobility R scripts are stored in the r_utils folder. - + script_path : str | pathlib.Path | contextlib._GeneratorContextManager + Path to the R script (mobility R scripts live in r_utils). """ - + def __init__(self, script_path): if isinstance(script_path, contextlib._GeneratorContextManager): with script_path as p: @@ -29,11 +30,63 @@ def __init__(self, script_path): elif isinstance(script_path, str): self.script_path = script_path else: - raise ValueError("R script path should be provided as str, pathlib.Path or contextlib._GeneratorContextManager") - - if pathlib.Path(self.script_path).exists() is False: + raise ValueError("R script path should be str, pathlib.Path or a context manager") + + if not pathlib.Path(self.script_path).exists(): raise ValueError("Rscript not found : " + self.script_path) + def _normalized_args(self, args: list) -> list: + """ + Ensure the download method is valid for the current OS. + The R script expects: + args[1] -> packages JSON (after we prepend package root) + args[2] -> force_reinstall (as string "TRUE"/"FALSE") + args[3] -> download_method + """ + norm = list(args) + if not norm: + return norm + + # The last argument should be the download method; normalize it for Linux + is_windows = (platform.system() == "Windows") + dl_idx = len(norm) - 1 + method = str(norm[dl_idx]).strip().lower() + + if not is_windows: + # Never use wininet/auto on Linux/WSL + if method in ("", "auto", "wininet"): + norm[dl_idx] = "libcurl" + else: + # On Windows, allow wininet; default to wininet if empty + if method == "": + norm[dl_idx] = "wininet" + + return norm + + def _build_env(self) -> dict: + """ + Prepare environment variables for R in a robust, cross-platform way. + """ + env = os.environ.copy() + + is_windows = (platform.system() == "Windows") + # Default to disabling pak unless caller opts in + env.setdefault("USE_PAK", "false") + + # Make R downloads sane by default + if not is_windows: + # Force libcurl on Linux/WSL + env.setdefault("R_DOWNLOAD_FILE_METHOD", "libcurl") + # Point to the system CA bundle if available (WSL/Ubuntu) + cacert = "/etc/ssl/certs/ca-certificates.crt" + if os.path.exists(cacert): + env.setdefault("SSL_CERT_FILE", cacert) + + # Avoid tiny default timeouts in some R builds + env.setdefault("R_DEFAULT_INTERNET_TIMEOUT", "600") + + return env + def run(self, args: list) -> None: """ Run the R script. @@ -41,24 +94,29 @@ def run(self, args: list) -> None: Parameters ---------- args : list - List of arguments to pass to the R function. + Arguments to pass to the R script (without the package root; we prepend it). Raises ------ RScriptError - Exception when the R script returns an error. - - """ - # Prepend the package path to the argument list so the R script can - # know where it is run (useful when sourcing other R scripts). - args = [str(resources.files('mobility'))] + args + If the R script returns a non-zero exit code. + """ + # Prepend the package path so the R script knows the mobility root + args = [str(resources.files('mobility'))] + self._normalized_args(args) cmd = ["Rscript", self.script_path] + args - + if os.environ.get("MOBILITY_DEBUG") == "1": - logging.info("Running R script " + self.script_path + " with the following arguments :") + logging.info("Running R script %s with the following arguments :", self.script_path) logging.info(args) - - process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + env = self._build_env() + + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env + ) stdout_thread = threading.Thread(target=self.print_output, args=(process.stdout,)) stderr_thread = threading.Thread(target=self.print_output, args=(process.stderr, True)) @@ -67,43 +125,42 @@ def run(self, args: list) -> None: process.wait() stdout_thread.join() stderr_thread.join() - + if process.returncode != 0: raise RScriptError( """ - Rscript error (the error message is logged just before the error stack trace). - If you want more detail, you can print all R output by setting debug=True when calling set_params. - """ +Rscript error (the error message is logged just before the error stack trace). +If you want more detail, set MOBILITY_DEBUG=1 (or debug=True in set_params) to print all R output. + """.rstrip() ) - def print_output(self, stream, is_error=False): + def print_output(self, stream, is_error: bool = False): """ - Log all R messages if debug=True in set_params, log only important messages if not. + Log all R messages if debug=True; otherwise show INFO lines + errors. Parameters ---------- - stream : - R message. - is_error : bool, default=False - If the R message is an error or not. - + stream : + R process stream. + is_error : bool + Whether this stream is stderr. """ for line in iter(stream.readline, b""): msg = line.decode("utf-8", errors="replace") if os.environ.get("MOBILITY_DEBUG") == "1": logging.info(msg) - else: if "INFO" in msg: - msg = msg.split("]")[1] - msg = msg.strip() + # keep the message payload after the log level tag if present + parts = msg.split("]") + if len(parts) > 1: + msg = parts[1].strip() logging.info(msg) - elif is_error and "Error" in msg or "Erreur" in msg: + elif is_error and ("Error" in msg or "Erreur" in msg): logging.error("RScript execution failed, with the following message : " + msg) class RScriptError(Exception): """Exception for R errors.""" - pass diff --git a/pyproject.toml b/pyproject.toml index 66e06b17..183afd5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,10 @@ dependencies = [ "psutil", "networkx", "plotly", + "dash", + "dash-deck", + "pydeck", + "dash-mantine-components", "scikit-learn", "gtfs_kit" ] @@ -51,8 +55,9 @@ dev = [ "pytest", "pytest-cov", "pytest-dependency", + "dash[testing]", "sphinxcontrib-napoleon", - "myst_parser" + "myst_parser" ] spyder = [ @@ -77,4 +82,11 @@ mobility = [ [tool.setuptools.packages.find] where = ["."] include = ["mobility*"] -exclude = ["certs", "certs.*"] \ No newline at end of file +exclude = ["certs", "certs.*"] + +[tool.pytest.ini_options] +pythonpath = [ + ".", + "front", + "mobility" +] diff --git a/tests/front/conftest.py b/tests/front/conftest.py new file mode 100644 index 00000000..22bc1c7e --- /dev/null +++ b/tests/front/conftest.py @@ -0,0 +1,83 @@ +# tests/front/conftest.py +import pytest +import pandas as pd +import geopandas as gpd +from shapely.geometry import Polygon +import sys +from pathlib import Path + +def pytest_configure(config): + import builtins + # Répond "Yes" à toute demande d'input (évite la lecture du stdin pendant la collecte) + builtins.input = lambda *args, **kwargs: "Yes" + + # S'assurer que le dossier par défaut existe (utilisé par set_params) + default_projects = Path.home() / ".mobility" / "data" / "projects" + default_projects.mkdir(parents=True, exist_ok=True) + + +REPO_ROOT = Path(__file__).resolve().parents[2] # -> repository root +FRONT_DIR = REPO_ROOT / "front" +if str(FRONT_DIR) not in sys.path: + sys.path.insert(0, str(FRONT_DIR)) + + +@pytest.fixture +def sample_scn(): + poly = Polygon([ + (1.43, 43.60), (1.45, 43.60), + (1.45, 43.62), (1.43, 43.62), + (1.43, 43.60) + ]) + + zones_gdf = gpd.GeoDataFrame( + { + "transport_zone_id": ["Z1"], + "local_admin_unit_id": ["31555"], + "average_travel_time": [32.4], + "total_dist_km": [7.8], + "total_time_min": [45.0], + "share_car": [0.52], + "share_bicycle": [0.18], + "share_walk": [0.30], + "geometry": [poly], + }, + crs="EPSG:4326", + ) + + flows_df = pd.DataFrame({"from": [], "to": [], "flow_volume": []}) + zones_lookup = zones_gdf[["transport_zone_id", "geometry"]].copy() + + return {"zones_gdf": zones_gdf, "flows_df": flows_df, "zones_lookup": zones_lookup} + + +@pytest.fixture(autouse=True) +def patch_services(monkeypatch, sample_scn): + # Patch le service scénario pour les tests d’intégration + import front.app.services.scenario_service as scn_mod + + def fake_get_scenario(radius=40, local_admin_unit_id="31555"): + return sample_scn + + monkeypatch.setattr(scn_mod, "get_scenario", fake_get_scenario, raising=True) + + # Patch map_service option B (si présent) + try: + import front.app.services.map_service as map_service + from app.components.features.map.config import DeckOptions + from app.components.features.map.deck_factory import make_deck_json + + def fake_get_map_deck_json_from_scn(scn, opts=None): + opts = opts or DeckOptions() + return make_deck_json(scn, opts) + + monkeypatch.setattr( + map_service, + "get_map_deck_json_from_scn", + fake_get_map_deck_json_from_scn, + raising=False, + ) + except Exception: + pass + + yield diff --git a/tests/front/integration/test_001_main_app.py b/tests/front/integration/test_001_main_app.py new file mode 100644 index 00000000..6da73131 --- /dev/null +++ b/tests/front/integration/test_001_main_app.py @@ -0,0 +1,108 @@ +""" +test_callbacks_simulation.py +============================ + +Tests de la logique de simulation associée au callback `_run_simulation` +sans recourir à Selenium / dash_duo. + +L’objectif est de : +- monkeypatcher `get_scenario` pour obtenir un scénario stable et déterministe ; +- exécuter un helper (`compute_simulation_outputs_test`) qui reproduit la logique + du callback (construction de `deck_json` + `StudyAreaSummary`) ; +- vérifier que la spécification Deck.gl produite est valide et que le composant + résumé est bien un composant Dash sérialisable. +""" + +import json +import pandas as pd +import geopandas as gpd +from shapely.geometry import Polygon +from dash.development.base_component import Component + +# On importe les briques "front" utilisées par le callback +import front.app.services.scenario_service as scn_mod +from app.components.features.map.config import DeckOptions +from app.components.features.map.deck_factory import make_deck_json +from app.components.features.study_area_summary import StudyAreaSummary + +MAPP = "map" # doit matcher l'id_prefix de la Map + + +def compute_simulation_outputs_test(radius_val, lau_val, id_prefix=MAPP): + """Reproduit la logique du callback `_run_simulation` dans un contexte de test. + + Ce helper permet de tester la génération de la carte et du panneau de résumé + sans démarrer le serveur Dash ni utiliser Selenium. Il : + - normalise le rayon et le code INSEE/LAU ; + - appelle `get_scenario` (qui peut être monkeypatché dans le test) ; + - construit la spec Deck.gl JSON via `make_deck_json` ; + - construit le composant `StudyAreaSummary`. + + Args: + radius_val: Valeur du rayon (slider / input) telle que fournie par l’UI. + lau_val: Code INSEE/LAU saisi (ex. "31555"). + id_prefix (str, optional): Préfixe d’identifiants pour le composant + `StudyAreaSummary`. Doit être cohérent avec `MAPP`. Par défaut `"map"`. + + Returns: + Tuple[str, Component]: + - `deck_json`: spécification Deck.gl sérialisée en JSON, + - `summary`: composant Dash `StudyAreaSummary`. + """ + r = 40 if radius_val is None else int(radius_val) + lau = (lau_val or "").strip() or "31555" + scn = scn_mod.get_scenario(radius=r, local_admin_unit_id=lau) + deck_json = make_deck_json(scn, DeckOptions()) + summary = StudyAreaSummary(scn["zones_gdf"], visible=True, id_prefix=id_prefix) + return deck_json, summary + + +def test_compute_simulation_outputs_smoke(monkeypatch): + # --- 1) scénario stable via monkeypatch --- + poly = Polygon([ + (1.43, 43.60), (1.45, 43.60), + (1.45, 43.62), (1.43, 43.62), + (1.43, 43.60), + ]) + + zones_gdf = gpd.GeoDataFrame( + { + "transport_zone_id": ["Z1"], + "local_admin_unit_id": ["31555"], + "average_travel_time": [32.4], + "total_dist_km": [7.8], + "total_time_min": [45.0], + "share_car": [0.52], + "share_bicycle": [0.18], + "share_walk": [0.30], + "geometry": [poly], + }, + crs="EPSG:4326", + ) + flows_df = pd.DataFrame({"from": [], "to": [], "flow_volume": []}) + zones_lookup = zones_gdf[["transport_zone_id", "geometry"]].copy() + + def fake_get_scenario(radius=40, local_admin_unit_id="31555"): + return { + "zones_gdf": zones_gdf, + "flows_df": flows_df, + "zones_lookup": zones_lookup, + } + + monkeypatch.setattr(scn_mod, "get_scenario", fake_get_scenario, raising=True) + + # --- 2) exécute la logique "callback-like" --- + deck_json, summary = compute_simulation_outputs_test(30, "31555", id_prefix=MAPP) + + # --- 3) assertions : deck_json DeckGL valide --- + assert isinstance(deck_json, str) + deck = json.loads(deck_json) + assert "initialViewState" in deck + assert isinstance(deck.get("layers", []), list) + + # --- 4) assertions : summary est un composant Dash sérialisable --- + assert isinstance(summary, Component) + payload = summary.to_plotly_json() + assert isinstance(payload, dict) + # On peut vérifier l'ID racine utilisé dans StudyAreaSummary + assert payload.get("props", {}).get("id", "").endswith("-study-summary") diff --git a/tests/front/unit/main/test_004_main_import_branches.py b/tests/front/unit/main/test_004_main_import_branches.py new file mode 100644 index 00000000..3d24c60f --- /dev/null +++ b/tests/front/unit/main/test_004_main_import_branches.py @@ -0,0 +1,18 @@ +# --- Service vs Fallback: API attendue par les tests --- +from app.components.features.map.config import DeckOptions +from app.components.features.map.deck_factory import make_deck_json as _fallback_make_deck_json + +try: + # IMPORTANT : chemin d'import attendu par les tests + from front.app.services.map_service import get_map_deck_json_from_scn as _svc_get_map_deck_json_from_scn + USE_MAP_SERVICE = True +except Exception: + _svc_get_map_deck_json_from_scn = None + USE_MAP_SERVICE = False + +def _make_deck_json_from_scn(scn, opts: DeckOptions | None = None) -> str: + """Garde une API stable pour tests_004_main_import_branches.py""" + opts = opts or DeckOptions() + if USE_MAP_SERVICE and _svc_get_map_deck_json_from_scn is not None: + return _svc_get_map_deck_json_from_scn(scn, opts) + return _fallback_make_deck_json(scn, opts) diff --git a/tests/front/unit/services/test_scenario_service.py b/tests/front/unit/services/test_scenario_service.py new file mode 100644 index 00000000..9da6e104 --- /dev/null +++ b/tests/front/unit/services/test_scenario_service.py @@ -0,0 +1,191 @@ +# tests/front/unit/scenario_service/test_scenario_service.py +import sys +import types +import builtins + +import numpy as np +import pandas as pd +import geopandas as gpd +from shapely.geometry import Point +from pyproj import CRS + +import app.services.scenario_service as scn # <-- adjust if your path differs + + +# ---------- helpers ---------- + +def _install_fake_mobility(monkeypatch, n=3): + """ + Install a fake 'mobility' module in sys.modules to drive the non-fallback path. + Creates n transport zones with simple point geometries in EPSG:4326. + """ + class _FakeTZ: + def __init__(self, local_admin_unit_id, radius, level_of_detail): + self.lau = local_admin_unit_id + self.radius = radius + self.lod = level_of_detail + + def get(self): + pts = [(1.0 + 0.01 * i, 43.0 + 0.01 * i) for i in range(n)] + df = gpd.GeoDataFrame( + { + "transport_zone_id": [f"Z{i+1}" for i in range(n)], + "geometry": [Point(x, y) for (x, y) in pts], + "local_admin_unit_id": [self.lau] * n, + }, + geometry="geometry", + crs=4326, + ) + return df + + fake = types.ModuleType("mobility") + fake.set_params = lambda **kwargs: None + fake.TransportZones = _FakeTZ + monkeypatch.setitem(sys.modules, "mobility", fake) + + +def _remove_mobility(monkeypatch): + """Force mobility import to fail, so we exercise the fallback branch.""" + if "mobility" in sys.modules: + monkeypatch.delitem(sys.modules, "mobility", raising=False) + + real_import = builtins.__import__ + + def fake_import(name, *a, **kw): + if name == "mobility": + raise ImportError("Simulated missing mobility") + return real_import(name, *a, **kw) + + monkeypatch.setattr(builtins, "__import__", fake_import) + + +# ---------- tests ---------- + +def test_fallback_used_when_mobility_missing(monkeypatch): + scn.clear_scenario_cache() + _remove_mobility(monkeypatch) + + out = scn.get_scenario(local_admin_unit_id="31555", radius=40) + zones = out["zones_gdf"] + flows = out["flows_df"] + lookup = out["zones_lookup"] + + # Shape/schema sanity + assert isinstance(zones, gpd.GeoDataFrame) + assert isinstance(flows, pd.DataFrame) + assert isinstance(lookup, gpd.GeoDataFrame) + assert zones.crs is not None and CRS(zones.crs).equals(CRS.from_epsg(4326)) + assert lookup.crs is not None and CRS(lookup.crs).equals(CRS.from_epsg(4326)) + + # All modal columns should exist + cols = { + "share_car", "share_bicycle", "share_walk", "share_carpool", + "share_pt_walk", "share_pt_car", "share_pt_bicycle", "share_public_transport", + "average_travel_time", "total_dist_km", "local_admin_unit_id", + } + assert cols.issubset(set(zones.columns)) + + # Shares well-formed (sum of all modal columns = 1) + row_sums = zones[[ + "share_car", "share_bicycle", "share_walk", "share_carpool", + "share_pt_walk", "share_pt_car", "share_pt_bicycle" + ]].sum(axis=1) + assert np.allclose(row_sums.values, 1.0, atol=1e-6) + + +def test_normalized_key_and_cache(monkeypatch): + scn.clear_scenario_cache() + _remove_mobility(monkeypatch) + + # _normalized_key behavior + assert scn._normalized_key("31555", 40) == ("fr-31555", 40.0) + assert scn._normalized_key("fr-31555", 40.00001) == ("fr-31555", 40.0) + + # Count compute calls via monkeypatched _compute_scenario + calls = {"n": 0} + + def fake_compute(local_admin_unit_id, radius, transport_modes_params): + calls["n"] += 1 + return scn._fallback_scenario() + + monkeypatch.setattr(scn, "_compute_scenario", fake_compute) + + # No params -> cached + scn.get_scenario(local_admin_unit_id="31555", radius=40.0, transport_modes_params=None) + scn.get_scenario(local_admin_unit_id="31555", radius=40.0, transport_modes_params=None) + assert calls["n"] == 1, "Second call without params should hit cache" + + # With params -> bypass cache each time + scn.get_scenario(local_admin_unit_id="31555", radius=40.0, transport_modes_params={"car": {"active": True}}) + scn.get_scenario(local_admin_unit_id="31555", radius=40.0, transport_modes_params={"car": {"active": True}}) + assert calls["n"] == 3, "Calls with params should call compute each time" + + +def test_non_fallback_renormalization_and_crs(monkeypatch): + scn.clear_scenario_cache() + _install_fake_mobility(monkeypatch, n=3) + + # Only bicycle active → its share should be 1, others 0 after renormalization + params = { + "car": {"active": False}, + "bicycle": {"active": True}, + "walk": {"active": False}, + "carpool": {"active": False}, + "public_transport": {"active": False}, + } + out = scn.get_scenario(local_admin_unit_id="31555", radius=40.0, transport_modes_params=params) + zones = out["zones_gdf"] + + # CRS robust equality to WGS84 + assert zones.crs is not None + assert CRS(zones.crs).equals(CRS.from_epsg(4326)) + + # Row-wise sum over all share columns ≈ 1 + cols = [ + "share_car", "share_bicycle", "share_walk", "share_carpool", + "share_pt_walk", "share_pt_car", "share_pt_bicycle" + ] + row_sums = zones[cols].sum(axis=1).to_numpy() + assert np.allclose(row_sums, 1.0, atol=1e-5) + + # Bicycle should be 1 everywhere; others 0 + assert np.allclose(zones["share_bicycle"].to_numpy(), 1.0, atol=1e-6) + others = zones[["share_car", "share_walk", "share_carpool", + "share_pt_walk", "share_pt_car", "share_pt_bicycle"]].to_numpy() + assert np.allclose(others, 0.0, atol=1e-6) + + +def test_pt_submodes_selection(monkeypatch): + scn.clear_scenario_cache() + _install_fake_mobility(monkeypatch, n=4) + + # PT active but only 'car_pt' enabled + params = { + "car": {"active": False}, + "bicycle": {"active": False}, + "walk": {"active": False}, + "carpool": {"active": False}, + "public_transport": {"active": True, "pt_walk": False, "pt_bicycle": False, "pt_car": True}, + } + out = scn.get_scenario(local_admin_unit_id="31555", radius=30.0, transport_modes_params=params) + zones = out["zones_gdf"] + + # public_transport share == share_pt_car ; others zero + assert np.all(zones["share_pt_walk"].values == 0.0) + assert np.all(zones["share_pt_bicycle"].values == 0.0) + assert np.all(zones["share_public_transport"].values == zones["share_pt_car"].values) + + # Total over active columns must be 1 + sums = zones[["share_pt_car"]].sum(axis=1) + assert np.allclose(sums.values, 1.0, atol=1e-6) + + +def test_lau_normalization_variants(monkeypatch): + scn.clear_scenario_cache() + _install_fake_mobility(monkeypatch, n=2) + + out_a = scn.get_scenario(local_admin_unit_id="31555", radius=40.0, transport_modes_params=None) + out_b = scn.get_scenario(local_admin_unit_id="fr-31555", radius=40.0, transport_modes_params=None) + + # Both normalize to ("fr-31555", 40.0) and use the same cached object + assert out_a is out_b diff --git a/tests/front/unit/test_002_color_scale.py b/tests/front/unit/test_002_color_scale.py new file mode 100644 index 00000000..8e2c141a --- /dev/null +++ b/tests/front/unit/test_002_color_scale.py @@ -0,0 +1,29 @@ +import pandas as pd +import numpy as np +from pytest import approx + +from app.components.features.map.color_scale import fit_color_scale + + +def test_fit_color_scale_basic(): + s = pd.Series([10.0, 20.0, 25.0, 30.0]) + scale = fit_color_scale(s) + + # vmin/vmax basés sur les percentiles 5/95 (et pas min/max stricts) + vmin_expected = float(np.nanpercentile(s, 5)) + vmax_expected = float(np.nanpercentile(s, 95)) + assert scale.vmin == approx(vmin_expected) + assert scale.vmax == approx(vmax_expected) + + # Couleur à vmin ~ froide, à vmax ~ chaude + c_min = scale.rgba(s.min()) + c_mid = scale.rgba(s.mean()) + c_max = scale.rgba(s.max()) + assert isinstance(c_min, list) and len(c_min) == 4 + assert c_min[0] < c_max[0] # rouge augmente + assert c_min[2] > c_max[2] # bleu diminue + + # Légende numérique "x.x min" + lg = scale.legend(11.0) + assert isinstance(lg, str) + assert lg.endswith(" min") diff --git a/tests/front/unit/test_003_geo_utils.py b/tests/front/unit/test_003_geo_utils.py new file mode 100644 index 00000000..20bdad85 --- /dev/null +++ b/tests/front/unit/test_003_geo_utils.py @@ -0,0 +1,24 @@ +import geopandas as gpd +from shapely.geometry import Polygon +from app.components.features.map.geo_utils import safe_center, ensure_wgs84 + +def test_safe_center_simple_square(): + gdf = gpd.GeoDataFrame( + {"id": [1]}, + geometry=[Polygon([(0,0),(1,0),(1,1),(0,1),(0,0)])], + crs="EPSG:4326", + ) + center = safe_center(gdf) + assert isinstance(center, tuple) and len(center) == 2 + lon, lat = center + assert 0.4 < lon < 0.6 + assert 0.4 < lat < 0.6 + +def test_ensure_wgs84_no_crs(): + gdf = gpd.GeoDataFrame( + {"id": [1]}, + geometry=[Polygon([(0,0),(1,0),(1,1),(0,1),(0,0)])], + crs=None, + ) + out = ensure_wgs84(gdf) + assert out.crs is not None \ No newline at end of file