Spaces:
Running
Running
File size: 8,009 Bytes
b81a86b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 | """
db/db_loader.py — Postgres/Supabase backend for AgriFlow data.
Mirrors the exact return signature of sample_data.loader.load_all_sample_data()
so the two loaders are drop-in substitutes behind the DATA_BACKEND env flag.
Return dict keys (same as CSV loader):
kabupaten -> Dict[str, Kabupaten]
komoditas -> Dict[str, Commodity]
surplus -> List[SupplyNode]
deficit -> List[DemandNode]
weather -> Dict[str, WeatherForecast]
historical_prices -> Dict[str, Tuple[float, float]]
Requires SUPABASE_DB_URL env var (postgresql+psycopg2://user:pass@host:port/db).
Raises RuntimeError immediately if the env var is absent so the caller sees a
clear error rather than a confusing connection exception later.
Usage (when creds are live):
import os
os.environ["SUPABASE_DB_URL"] = "postgresql+psycopg2://postgres:<pw>@<host>:5432/postgres"
from db.db_loader import load_all
data = load_all()
"""
from __future__ import annotations
import os
from datetime import datetime
from typing import Dict, List, Tuple
# ---------------------------------------------------------------------------
# SQLAlchemy import — optional at module-import time so the package stays
# importable on installs that don't have sqlalchemy yet (test (b) just needs
# the RuntimeError path, not an ImportError).
# ---------------------------------------------------------------------------
try:
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
_SQLALCHEMY_AVAILABLE = True
except ImportError: # pragma: no cover
_SQLALCHEMY_AVAILABLE = False
from matching_engine.models import (
Commodity,
DemandNode,
Kabupaten,
SupplyNode,
Tier,
WeatherForecast,
)
_ENV_KEY = "SUPABASE_DB_URL"
def _require_db_url() -> str:
"""Return the DB URL or raise a clear RuntimeError."""
url = os.environ.get(_ENV_KEY, "").strip()
if not url:
raise RuntimeError(
f"Postgres backend requested but {_ENV_KEY!r} env var is not set. "
"Set it to a valid DSN, e.g.:\n"
" postgresql+psycopg2://postgres:<password>@<host>:5432/postgres\n"
"Or switch to the CSV backend by setting DATA_BACKEND=csv (default)."
)
return url
def _get_engine() -> "Engine":
# Check env var first so operators get a clear "set SUPABASE_DB_URL" message
# even when sqlalchemy is not yet installed.
url = _require_db_url()
if not _SQLALCHEMY_AVAILABLE:
raise RuntimeError( # pragma: no cover
"sqlalchemy is not installed. Run: pip install sqlalchemy psycopg2-binary"
)
return create_engine(url, pool_pre_ping=True, future=True)
# ---------------------------------------------------------------------------
# Per-table loaders
# ---------------------------------------------------------------------------
def _load_kabupaten(engine: "Engine") -> Dict[str, Kabupaten]:
query = text("""
SELECT kab_id, nama, latitude, longitude, ipm_2024, population_2024, tier
FROM kabupaten
ORDER BY kab_id
""")
out: Dict[str, Kabupaten] = {}
with engine.connect() as conn:
rows = conn.execute(query).fetchall()
for row in rows:
tier = Tier.HIGH if row.tier == "TIER_1_HIGH" else Tier.MEDIUM
out[row.kab_id] = Kabupaten(
id=row.kab_id,
nama=row.nama,
latitude=float(row.latitude),
longitude=float(row.longitude),
ipm=float(row.ipm_2024),
tier=tier,
population=int(row.population_2024),
)
return out
def _load_komoditas(engine: "Engine") -> Dict[str, Commodity]:
query = text("""
SELECT code, nama, max_distance_km, min_viable_tons, max_fresh_age_days
FROM commodity
ORDER BY code
""")
out: Dict[str, Commodity] = {}
with engine.connect() as conn:
rows = conn.execute(query).fetchall()
for row in rows:
out[row.code] = Commodity(
code=row.code,
nama=row.nama,
max_distance_km=float(row.max_distance_km),
min_viable_tons=float(row.min_viable_tons),
max_fresh_age_days=int(row.max_fresh_age_days),
)
return out
def _load_surplus_deficit(
engine: "Engine",
kabupaten: Dict[str, Kabupaten],
komoditas: Dict[str, Commodity],
) -> Tuple[List[SupplyNode], List[DemandNode]]:
query = text("""
SELECT kab_id, commodity_code, role, volume_tons,
price_idr_per_kg, harvest_age_days
FROM surplus_deficit
ORDER BY kab_id, commodity_code
""")
surplus: List[SupplyNode] = []
deficit: List[DemandNode] = []
now = datetime.now()
with engine.connect() as conn:
rows = conn.execute(query).fetchall()
for row in rows:
kab = kabupaten[row.kab_id]
komo = komoditas[row.commodity_code]
if row.role == "SURPLUS":
surplus.append(SupplyNode(
kabupaten=kab,
commodity=komo,
volume_tons=float(row.volume_tons),
price_per_kg=float(row.price_idr_per_kg),
harvest_age_days=int(row.harvest_age_days),
timestamp=now,
data_source="POSTGRES",
))
elif row.role == "DEFICIT":
deficit.append(DemandNode(
kabupaten=kab,
commodity=komo,
volume_tons=float(row.volume_tons),
price_per_kg=float(row.price_idr_per_kg),
timestamp=now,
data_source="POSTGRES",
))
else:
raise ValueError(f"Unknown role in surplus_deficit table: {row.role!r}")
return surplus, deficit
def _load_weather(engine: "Engine") -> Dict[str, WeatherForecast]:
query = text("""
SELECT origin_kab_id, dest_kab_id, max_rain_mm, transit_window_days, source
FROM weather_forecast
ORDER BY origin_kab_id, dest_kab_id
""")
out: Dict[str, WeatherForecast] = {}
with engine.connect() as conn:
rows = conn.execute(query).fetchall()
for row in rows:
key = f"{row.origin_kab_id}_{row.dest_kab_id}"
out[key] = WeatherForecast(
origin_kab_id=row.origin_kab_id,
dest_kab_id=row.dest_kab_id,
max_rain_mm=float(row.max_rain_mm),
transit_window_days=int(row.transit_window_days),
source=row.source,
)
return out
def _load_historical_prices(engine: "Engine") -> Dict[str, Tuple[float, float]]:
query = text("""
SELECT commodity_code, median_idr_per_kg, std_idr_per_kg
FROM historical_prices
ORDER BY commodity_code
""")
out: Dict[str, Tuple[float, float]] = {}
with engine.connect() as conn:
rows = conn.execute(query).fetchall()
for row in rows:
out[row.commodity_code] = (
float(row.median_idr_per_kg),
float(row.std_idr_per_kg),
)
return out
# ---------------------------------------------------------------------------
# Public API — mirrors sample_data.loader.load_all_sample_data() exactly
# ---------------------------------------------------------------------------
def load_all() -> dict:
"""
Load all AgriFlow reference data from Postgres.
Return dict with keys:
kabupaten, komoditas, surplus, deficit, weather, historical_prices
Raises RuntimeError if SUPABASE_DB_URL is not set.
"""
engine = _get_engine()
kab = _load_kabupaten(engine)
komo = _load_komoditas(engine)
surplus, deficit = _load_surplus_deficit(engine, kab, komo)
weather = _load_weather(engine)
historical = _load_historical_prices(engine)
return {
"kabupaten": kab,
"komoditas": komo,
"surplus": surplus,
"deficit": deficit,
"weather": weather,
"historical_prices": historical,
}
|