Spaces:
Running on Zero
Running on Zero
| """ | |
| Catchment (drainage basin) area loader. | |
| Single Responsibility: Load per-station catchment/drainage-basin area, | |
| as produced by download_catchment_area.py from Hub'Eau's own | |
| hydrometrie referentiel (referentiel/sites, field `surface_bv`). | |
| """ | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| from pathlib import Path | |
| from typing import Optional | |
| from .base import BaseDataLoader | |
| class CatchmentAreaLoader(BaseDataLoader): | |
| """ | |
| Loads per-station catchment (drainage basin) area in km². | |
| Hub'Eau doesn't publish surface_bv for every site — some rows will | |
| have a missing catchment_area_km2 (e.g. observer/manual stations, | |
| partner-network sites). That's expected, not a loading error; see | |
| `coverage()` to check how much of your station set has real data | |
| before relying on it (e.g. for drainage-area-ratio discharge scaling). | |
| """ | |
| def __init__(self, data_path: Path): | |
| """ | |
| Initialize catchment area loader. | |
| Args: | |
| data_path: Path to catchment_area.csv | |
| (columns: station_code, code_site, catchment_area_km2) | |
| """ | |
| super().__init__(data_path) | |
| def load(self) -> pd.DataFrame: | |
| """ | |
| Load catchment area data. | |
| Returns: | |
| DataFrame with [station_code, code_site, catchment_area_km2] | |
| """ | |
| df = pd.read_csv(self.data_path) | |
| required = {"station_code", "catchment_area_km2"} | |
| missing = required - set(df.columns) | |
| if missing: | |
| raise ValueError(f"catchment_area.csv is missing expected column(s): {missing}") | |
| return df | |
| def coverage(self, df: Optional[pd.DataFrame] = None) -> dict: | |
| """ | |
| Quick summary of how many stations actually have a catchment | |
| area value, since Hub'Eau leaves this blank for a meaningful | |
| fraction of sites. | |
| Returns: | |
| {"n_stations": int, "n_with_area": int, "fraction": float} | |
| """ | |
| if df is None: | |
| df = self.load() | |
| n_total = len(df) | |
| n_with = int(df["catchment_area_km2"].notna().sum()) | |
| return { | |
| "n_stations": n_total, | |
| "n_with_area": n_with, | |
| "fraction": n_with / n_total if n_total else 0.0, | |
| } | |
| def get_metadata(self) -> dict: | |
| """Get catchment area metadata.""" | |
| meta = super().get_metadata() | |
| meta.update({ | |
| "data_type": "catchment_area", | |
| "source_organization": "Hub'Eau (hydrometrie referentiel/sites, surface_bv)", | |
| "units": "km2", | |
| }) | |
| return meta | |
| def plot_coverage( | |
| self, | |
| df: Optional[pd.DataFrame] = None, | |
| figsize: tuple = (5, 5), | |
| save_path: Optional[Path] = None, | |
| ) -> plt.Axes: | |
| """ | |
| Simple pie/bar of how many stations have a catchment area value | |
| vs. how many don't — a quick sanity check before trusting this | |
| feature for drainage-area scaling. | |
| Args: | |
| df: Optional pre-loaded DataFrame. If not provided, data is loaded from disk. | |
| figsize: Figure size in inches. | |
| save_path: If provided, saves the figure to this path. | |
| Returns: | |
| The matplotlib Axes object. | |
| """ | |
| if df is None: | |
| df = self.load() | |
| cov = self.coverage(df) | |
| fig, ax = plt.subplots(figsize=figsize) | |
| counts = [cov["n_with_area"], cov["n_stations"] - cov["n_with_area"]] | |
| labels = [f"Has area\n({counts[0]})", f"No area\n({counts[1]})"] | |
| ax.pie(counts, labels=labels, colors=["#2E6F95", "#C7CDC3"], | |
| autopct="%1.0f%%", startangle=90, | |
| textprops={"fontsize": 10}) | |
| ax.set_title(f"Catchment Area Coverage ({cov['n_stations']} stations)", | |
| fontsize=12, fontweight="bold") | |
| plt.tight_layout() | |
| if save_path: | |
| plt.savefig(save_path, dpi=150, bbox_inches="tight") | |
| print(f"Plot saved to {save_path}") | |
| return ax | |
| def plot_by_station( | |
| self, | |
| df: Optional[pd.DataFrame] = None, | |
| stations: Optional[list] = None, | |
| figsize: tuple = (10, 8), | |
| save_path: Optional[Path] = None, | |
| ) -> plt.Axes: | |
| """ | |
| Horizontal bar chart of catchment area per station, sorted | |
| smallest to largest. Stations without data are omitted (see | |
| `plot_coverage` for how many that is). | |
| Args: | |
| df: Optional pre-loaded DataFrame. If not provided, data is loaded from disk. | |
| stations: Optional list of station codes to include. Defaults to all with data. | |
| figsize: Figure size in inches. | |
| save_path: If provided, saves the figure to this path. | |
| Returns: | |
| The matplotlib Axes object. | |
| """ | |
| if df is None: | |
| df = self.load() | |
| plot_df = df.dropna(subset=["catchment_area_km2"]).copy() | |
| if stations: | |
| plot_df = plot_df[plot_df["station_code"].isin(stations)] | |
| if plot_df.empty: | |
| raise ValueError("No stations with catchment area data to plot") | |
| plot_df = plot_df.sort_values("catchment_area_km2") | |
| fig, ax = plt.subplots(figsize=figsize) | |
| ax.barh(plot_df["station_code"], plot_df["catchment_area_km2"], color="#2E6F95") | |
| ax.set_title("Catchment Area by Station", fontsize=12, fontweight="bold") | |
| ax.set_xlabel("Catchment area (km²)") | |
| ax.set_ylabel("Station") | |
| ax.grid(True, alpha=0.3, axis="x") | |
| plt.tight_layout() | |
| if save_path: | |
| plt.savefig(save_path, dpi=150, bbox_inches="tight") | |
| print(f"Plot saved to {save_path}") | |
| return ax |