microplastinet / src /m4_dashboard /data_loader.py
naidusai's picture
Initial deploy: MicroPlastiNet Dash dashboard (synthetic data, honest disclosure)
3a5b233 verified
Raw
History Blame Contribute Delete
10.5 kB
"""
data_loader.py — MicroPlastiNet M4 Dashboard Data Loader
MOCK_DATA=True → generates realistic synthetic data (default, no upstream deps)
MOCK_DATA=False → loads from M2a/M2b/M3 outputs
"""
import os
import json
import numpy as np
import pandas as pd
from datetime import datetime, timedelta
from pathlib import Path
# ─── Configuration ────────────────────────────────────────────────────────────
MOCK_DATA = os.environ.get("MOCK_DATA", "true").lower() != "false"
BASE_DIR = Path(__file__).resolve().parent.parent.parent
ASSETS_DIR = BASE_DIR / "assets"
M3_OUTPUT = BASE_DIR / "src" / "m3_graph_gnn" / "outputs" / "attribution_results.json"
# ─── Station Metadata ──────────────────────────────────────────────────────────
# 50 stations across Ogeechee, Savannah, Altamaha river corridors in coastal Georgia
RIVER_SYSTEMS = {
"Ogeechee": {
"color": "#0284c7",
"lat_range": (31.9, 32.6),
"lon_range": (-81.8, -81.0),
"n_stations": 17,
},
"Savannah": {
"color": "#ea580c",
"lat_range": (32.0, 32.8),
"lon_range": (-81.2, -80.9),
"n_stations": 16,
},
"Altamaha": {
"color": "#16a34a",
"lat_range": (31.3, 31.9),
"lon_range": (-81.7, -81.1),
"n_stations": 17,
},
}
POLYMER_TYPES = ["PE", "PET", "PP", "PS", "PVC", "Other"]
# Color palette reference (also used by callbacks)
COLORS = {
"bg_deep": "#f5f7fa",
"bg_panel": "#ffffff",
"bg_card": "#ffffff",
"accent_cyan": "#0284c7",
"accent_teal": "#0d9488",
"accent_amber":"#d97706",
"accent_red": "#dc2626",
"accent_green":"#16a34a",
"text_primary":"#0f172a",
"text_muted": "#64748b",
"border": "#e2e8f0",
"high": "#dc2626",
"medium": "#d97706",
"low": "#16a34a",
}
POLYMER_COLORS = {
"PE": "#0284c7", # blue
"PET": "#ea580c", # orange
"PP": "#d97706", # amber
"PS": "#7c3aed", # violet
"PVC": "#dc2626", # red
"Other": "#0d9488", # teal
}
SOURCE_TYPES = [
"Upstream Wastewater Outfall",
"Urban Stormwater Runoff",
"Agricultural Drainage",
"Industrial Discharge",
"Marine Vessel Traffic",
"Atmospheric Deposition",
"Coastal Erosion",
]
def _seed_rng(seed=42):
return np.random.default_rng(seed)
def load_station_metadata() -> pd.DataFrame:
"""Return DataFrame of 50 sensor stations with lat/lon, river, status."""
if not MOCK_DATA:
meta_path = BASE_DIR / "data" / "processed" / "station_metadata.csv"
if meta_path.exists():
return pd.read_csv(meta_path)
rng = _seed_rng(42)
records = []
station_id = 1
for river, cfg in RIVER_SYSTEMS.items():
n = cfg["n_stations"]
lats = rng.uniform(*cfg["lat_range"], n)
lons = rng.uniform(*cfg["lon_range"], n)
for i in range(n):
# Assign contamination level (determines dot color on map)
base_level = rng.uniform(0, 100)
status = "HIGH" if base_level > 66 else ("MEDIUM" if base_level > 33 else "LOW")
records.append({
"station_id": f"STN-{station_id:03d}",
"name": f"{river} Stn {i+1}",
"river": river,
"lat": round(float(lats[i]), 5),
"lon": round(float(lons[i]), 5),
"status": status,
"mp_conc": round(float(base_level), 2), # particles/L
"temp_c": round(float(rng.uniform(18, 28)), 1),
"turbidity_ntu":round(float(rng.uniform(1, 45)), 1),
"ph": round(float(rng.uniform(6.5, 8.2)), 2),
"depth_m": round(float(rng.uniform(0.3, 4.5)), 1),
"install_date": f"202{rng.integers(1, 4)}-{rng.integers(1,12):02d}-{rng.integers(1,28):02d}",
"color": cfg["color"],
})
station_id += 1
return pd.DataFrame(records)
def load_time_series(station_id: str, days: int = 30) -> pd.DataFrame:
"""Return daily MP concentration time series for a station."""
if not MOCK_DATA:
ts_path = BASE_DIR / "data" / "processed" / "timeseries" / f"{station_id}.csv"
if ts_path.exists():
return pd.read_csv(ts_path, parse_dates=["date"])
rng = _seed_rng(sum(ord(c) for c in station_id))
end_date = datetime.now()
dates = [end_date - timedelta(days=i) for i in range(days, -1, -1)]
# Generate AR(1) process with seasonal component
base = float(rng.uniform(10, 60))
values = [base]
for d in dates[1:]:
seasonal = 8 * np.sin(2 * np.pi * d.timetuple().tm_yday / 365)
noise = float(rng.normal(0, 3))
new_val = max(0.1, 0.88 * values[-1] + 0.12 * base + seasonal + noise)
values.append(new_val)
# Inject 1-2 anomaly spikes
n_anomalies = rng.integers(1, 3)
anomaly_idx = rng.choice(range(5, len(values) - 2), n_anomalies, replace=False)
anomaly_flags = [False] * len(values)
for idx in anomaly_idx:
values[idx] += float(rng.uniform(30, 70))
anomaly_flags[idx] = True
return pd.DataFrame({
"date": pd.to_datetime(dates),
"mp_conc": [round(v, 2) for v in values],
"turbidity": [round(v + float(rng.normal(0, 2)), 2) for v in values],
"anomaly": anomaly_flags,
})
def load_polymer_breakdown(station_id: str) -> dict:
"""Return polymer type distribution for a station."""
if not MOCK_DATA:
poly_path = BASE_DIR / "data" / "processed" / "polymer" / f"{station_id}.json"
if poly_path.exists():
with open(poly_path) as f:
return json.load(f)
rng = _seed_rng(sum(ord(c) for c in station_id) + 1000)
raw = rng.dirichlet(alpha=[3, 2, 2, 1, 1, 1])
return {
"station_id": station_id,
"polymers": {p: round(float(v), 4) for p, v in zip(POLYMER_TYPES, raw)},
"confidence": {p: round(float(rng.uniform(0.72, 0.98)), 3) for p in POLYMER_TYPES},
"total_particles": int(rng.integers(120, 2400)),
}
def load_source_attribution(station_id: str, event_id: str = None) -> dict:
"""Return top-5 source attribution for a contamination event."""
if not MOCK_DATA and M3_OUTPUT.exists():
with open(M3_OUTPUT) as f:
data = json.load(f)
if station_id in data:
return data[station_id]
rng = _seed_rng(sum(ord(c) for c in station_id) + 9999)
n_sources = 5
chosen = rng.choice(SOURCE_TYPES, n_sources, replace=False)
probs_raw = rng.dirichlet(alpha=[4, 2.5, 2, 1.5, 1])
probs = sorted(zip(probs_raw, chosen), reverse=True)
# Realistic upstream source locations
source_lats = rng.uniform(31.8, 33.2, n_sources)
source_lons = rng.uniform(-82.5, -81.0, n_sources)
return {
"station_id": station_id,
"event_id": event_id or f"EVT-{rng.integers(1000, 9999)}",
"event_date": (datetime.now() - timedelta(days=int(rng.integers(1, 10)))).strftime("%Y-%m-%d"),
"sources": [
{
"rank": i + 1,
"name": name,
"probability": round(float(prob), 4),
"confidence": round(float(rng.uniform(0.7, 0.97)), 3),
"distance_km": round(float(rng.uniform(2, 45)), 1),
"lat": round(float(source_lats[i]), 5),
"lon": round(float(source_lons[i]), 5),
}
for i, (prob, name) in enumerate(probs)
],
}
def load_all_polymer_breakdown() -> pd.DataFrame:
"""Return polymer breakdown for ALL stations (for stacked bar chart)."""
stations = load_station_metadata()
records = []
for sid in stations["station_id"]:
pb = load_polymer_breakdown(sid)
row = {"station_id": sid}
row.update(pb["polymers"])
records.append(row)
return pd.DataFrame(records)
def load_forecast(station_id: str, days_ahead: int = 7) -> pd.DataFrame:
"""
Generate 7-day forecast using statsmodels SARIMA (or simple AR fallback).
Returns DataFrame with date, predicted_conc, lower_ci, upper_ci, alert.
"""
ts = load_time_series(station_id, days=60)
try:
from statsmodels.tsa.statespace.sarimax import SARIMAX
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore")
model = SARIMAX(
ts["mp_conc"],
order=(1, 0, 1),
seasonal_order=(1, 0, 0, 7),
enforce_stationarity=False,
enforce_invertibility=False,
)
result = model.fit(disp=False, maxiter=50)
forecast = result.get_forecast(steps=days_ahead)
pred = forecast.predicted_mean.values
ci = forecast.conf_int(alpha=0.2)
lower = ci.iloc[:, 0].values
upper = ci.iloc[:, 1].values
except Exception:
# Fallback: simple AR(1)-like extrapolation
last_val = float(ts["mp_conc"].iloc[-1])
rng = _seed_rng(hash(station_id) % 10000)
pred = [max(0, last_val + float(rng.normal(0, 5))) for _ in range(days_ahead)]
lower = [max(0, v - 15) for v in pred]
upper = [v + 20 for v in pred]
end_date = datetime.now()
future_dates = [end_date + timedelta(days=i + 1) for i in range(days_ahead)]
# Threshold: HIGH alert if predicted > 65 particles/L
alert_threshold = 65.0
return pd.DataFrame({
"date": pd.to_datetime(future_dates),
"predicted": [round(float(v), 2) for v in pred],
"lower_ci": [round(float(v), 2) for v in lower],
"upper_ci": [round(float(v), 2) for v in upper],
"alert": [float(v) > alert_threshold for v in pred],
})
def get_m3_graph_html() -> str | None:
"""Return path to M3 interactive graph HTML if available."""
graph_html = ASSETS_DIR / "m3_graph.html"
if graph_html.exists():
return str(graph_html)
return None
def get_map_token() -> str:
"""Return Mapbox token or empty string for open-street-map fallback."""
return os.environ.get("MAPBOX_TOKEN", "")