Spaces:
Running
Running
| """ | |
| AgriFlow Matching Engine β Layer 0 & Layer 1 (Constraints) | |
| =========================================================== | |
| Layer 0: Tier classification berdasarkan ID kabupaten. | |
| Layer 1: Hard constraints filtering β semua pair yang tidak lolos langsung di-reject. | |
| Sesuai Section 5.5.4 dari proposal v8/v9. | |
| Author: AgriFlow Team | |
| Version: 9.0 | |
| """ | |
| from __future__ import annotations | |
| import csv | |
| import math | |
| from datetime import timedelta | |
| from pathlib import Path | |
| from typing import Dict, Iterable, List, Optional, Set, Tuple | |
| from .models import ( | |
| Commodity, DemandNode, EmergencyMode, Kabupaten, | |
| LogisticsContext, SupplyNode, Tier, | |
| ) | |
| # ============================================================================= | |
| # LAYER 0 β TIER CLASSIFICATION | |
| # ============================================================================= | |
| # 8 kota IHK Jawa Timur (Section 5.5.3) | |
| TIER_1_KOTA_IHK: Set[str] = { | |
| "3578", # Surabaya | |
| "3573", # Malang (Kota) | |
| "3571", # Kediri (Kota) | |
| "3577", # Madiun (Kota) | |
| "3574", # Probolinggo (Kota) | |
| "3510", # Banyuwangi | |
| "3529", # Sumenep | |
| "3509", # Jember | |
| } | |
| def determine_tier(kabupaten_id: str) -> Tier: | |
| """ | |
| Layer 0: Tentukan tier kabupaten dari kode wilayah BPS. | |
| Latency target: <10ms (lookup di set). | |
| """ | |
| return Tier.HIGH if kabupaten_id in TIER_1_KOTA_IHK else Tier.MEDIUM | |
| # ============================================================================= | |
| # KOMODITAS SPECIFICATIONS (Section 5.5.4 Layer 1 hard constraints) | |
| # ============================================================================= | |
| # 19 komoditas Bapanas + tambahan strategis Jatim | |
| COMMODITY_SPECS: dict[str, Commodity] = { | |
| "cabai_merah": Commodity( | |
| code="cabai_merah", nama="Cabai Merah Besar", | |
| max_distance_km=200, min_viable_tons=1.0, max_fresh_age_days=5, | |
| ), | |
| "cabai_rawit": Commodity( | |
| code="cabai_rawit", nama="Cabai Rawit Merah", | |
| max_distance_km=200, min_viable_tons=1.0, max_fresh_age_days=5, | |
| ), | |
| "bawang_merah": Commodity( | |
| code="bawang_merah", nama="Bawang Merah", | |
| max_distance_km=400, min_viable_tons=2.0, max_fresh_age_days=30, | |
| ), | |
| "bawang_putih": Commodity( | |
| code="bawang_putih", nama="Bawang Putih (Bonggol)", | |
| max_distance_km=600, min_viable_tons=2.0, max_fresh_age_days=60, | |
| ), | |
| "tomat": Commodity( | |
| code="tomat", nama="Tomat Sayur", | |
| max_distance_km=150, min_viable_tons=1.0, max_fresh_age_days=7, | |
| ), | |
| "kentang": Commodity( | |
| code="kentang", nama="Kentang", | |
| max_distance_km=400, min_viable_tons=2.0, max_fresh_age_days=21, | |
| ), | |
| "kol": Commodity( | |
| code="kol", nama="Kol/Kubis", | |
| max_distance_km=300, min_viable_tons=2.0, max_fresh_age_days=14, | |
| ), | |
| "wortel": Commodity( | |
| code="wortel", nama="Wortel", | |
| max_distance_km=300, min_viable_tons=2.0, max_fresh_age_days=14, | |
| ), | |
| "beras_premium": Commodity( | |
| code="beras_premium", nama="Beras Premium", | |
| max_distance_km=800, min_viable_tons=5.0, max_fresh_age_days=180, | |
| ), | |
| "beras_medium": Commodity( | |
| code="beras_medium", nama="Beras Medium (Bulog)", | |
| max_distance_km=800, min_viable_tons=5.0, max_fresh_age_days=180, | |
| ), | |
| "jagung": Commodity( | |
| code="jagung", nama="Jagung Pipilan Kering", | |
| max_distance_km=600, min_viable_tons=5.0, max_fresh_age_days=120, | |
| ), | |
| "kedelai": Commodity( | |
| code="kedelai", nama="Kedelai Lokal", | |
| max_distance_km=600, min_viable_tons=3.0, max_fresh_age_days=120, | |
| ), | |
| "telur_ayam": Commodity( | |
| code="telur_ayam", nama="Telur Ayam Ras", | |
| max_distance_km=300, min_viable_tons=1.0, max_fresh_age_days=21, | |
| ), | |
| "daging_ayam": Commodity( | |
| code="daging_ayam", nama="Daging Ayam Ras", | |
| max_distance_km=200, min_viable_tons=0.5, max_fresh_age_days=3, | |
| ), | |
| "daging_sapi": Commodity( | |
| code="daging_sapi", nama="Daging Sapi Murni", | |
| max_distance_km=200, min_viable_tons=0.5, max_fresh_age_days=3, | |
| ), | |
| "gula_pasir": Commodity( | |
| code="gula_pasir", nama="Gula Pasir Lokal", | |
| max_distance_km=600, min_viable_tons=3.0, max_fresh_age_days=365, | |
| ), | |
| "minyak_goreng": Commodity( | |
| code="minyak_goreng", nama="Minyak Goreng Curah", | |
| max_distance_km=600, min_viable_tons=3.0, max_fresh_age_days=180, | |
| ), | |
| "ikan_segar": Commodity( | |
| code="ikan_segar", nama="Ikan Segar (Bandeng/Tongkol)", | |
| max_distance_km=150, min_viable_tons=0.5, max_fresh_age_days=2, | |
| ), | |
| "tepung_terigu": Commodity( | |
| code="tepung_terigu", nama="Tepung Terigu", | |
| max_distance_km=600, min_viable_tons=3.0, max_fresh_age_days=180, | |
| ), | |
| } | |
| def get_commodity(code: str) -> Commodity: | |
| """Lookup komoditas dengan error message yang jelas.""" | |
| if code not in COMMODITY_SPECS: | |
| raise ValueError( | |
| f"Komoditas tidak dikenali: '{code}'. " | |
| f"Valid: {sorted(COMMODITY_SPECS.keys())}" | |
| ) | |
| return COMMODITY_SPECS[code] | |
| # ============================================================================= | |
| # F1 β GRADE SUBSTITUTION | |
| # ============================================================================= | |
| # Skenario F1: surplus beras_premium dapat memenuhi demand beras_medium | |
| # (premium ke medium = upgrade, buyer tidak rugi). Direction-asymmetric: | |
| # medium β premium TIDAK diizinkan (buyer harapkan grade lebih tinggi). | |
| # Engine fires this branch HANYA jika `allow_grade_substitution=True` di | |
| # generate_candidates() β default False supaya 106 test baseline tidak | |
| # berubah perilaku. | |
| GRADE_SUBSTITUTION: dict[str, list[str]] = { | |
| "beras_premium": ["beras_medium"], | |
| # Future expansions (untuk roadmap): minyak_goreng_premium β minyak_curah, | |
| # gula_pasir_premium β gula_pasir_lokal, dst. | |
| } | |
| def grade_compatible(surplus_code: str, demand_code: str) -> bool: | |
| """ | |
| Apakah surplus dengan code A dapat memenuhi demand dengan code B? | |
| True jika: | |
| - A == B (commodity sama, default behavior) | |
| - A ada di GRADE_SUBSTITUTION dan B β GRADE_SUBSTITUTION[A] (premium β medium) | |
| False sebaliknya β medium β premium TIDAK kompatibel. | |
| """ | |
| if surplus_code == demand_code: | |
| return True | |
| return demand_code in GRADE_SUBSTITUTION.get(surplus_code, []) | |
| # ============================================================================= | |
| # DISTANCE CALCULATION | |
| # ============================================================================= | |
| def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: | |
| """ | |
| Hitung jarak great-circle dalam km antara 2 koordinat. | |
| Approximation untuk Layer 1 filtering (cepat, tidak akurat untuk routing). | |
| Dipakai sebagai fallback ketika tidak ada road-distance cache hit. | |
| """ | |
| R = 6371.0 # radius bumi km | |
| lat1_rad, lat2_rad = math.radians(lat1), math.radians(lat2) | |
| dlat = math.radians(lat2 - lat1) | |
| dlon = math.radians(lon2 - lon1) | |
| a = (math.sin(dlat / 2) ** 2 | |
| + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2) ** 2) | |
| c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) | |
| return R * c | |
| # ============================================================================= | |
| # OSRM ROAD-DISTANCE CACHE (precomputed via tools/fetch_osrm_distance.py) | |
| # ============================================================================= | |
| # Empirical finding (Jatim 38 kab, 1402 pairs): | |
| # median ratio road/haversine = 1.35x, p95 = 1.87x, max = 4.59x. | |
| # Outliers concentrated in Madura crossings (Sumenep<->Situbondo haversine | |
| # 80km, road 366km via Suramadu). For MAX_DISTANCE=200km commodities | |
| # (cabai/bawang/tomat/ikan_segar), haversine is over-permissive by 18.6% | |
| # of pairs β engine matches that were actually infeasible. | |
| # | |
| # This cache eliminates that gap for kabupaten present in the matrix. | |
| # Pairs not in the cache (e.g. inter-provinsi expansion) fall back to | |
| # haversine transparently. | |
| _ROAD_DISTANCE_CSV = ( | |
| Path(__file__).resolve().parent.parent | |
| / "sample_data" / "road_distance_jatim.csv" | |
| ) | |
| _ROAD_DISTANCE_CACHE: Optional[Dict[Tuple[str, str], float]] = None | |
| def _load_road_distance_cache() -> Dict[Tuple[str, str], float]: | |
| """Lazy-load + memoize the OSRM precompute on first lookup.""" | |
| global _ROAD_DISTANCE_CACHE | |
| if _ROAD_DISTANCE_CACHE is not None: | |
| return _ROAD_DISTANCE_CACHE | |
| cache: Dict[Tuple[str, str], float] = {} | |
| if _ROAD_DISTANCE_CSV.exists(): | |
| with open(_ROAD_DISTANCE_CSV, newline="", encoding="utf-8") as f: | |
| r = csv.DictReader(f) | |
| for row in r: | |
| cache[(row["from_id"], row["to_id"])] = float(row["road_km"]) | |
| _ROAD_DISTANCE_CACHE = cache | |
| return cache | |
| def road_distance_km(kab_a_id: str, kab_b_id: str) -> Optional[float]: | |
| """ | |
| Lookup OSRM road distance (km) between two kabupaten by ID. | |
| Returns None when the pair is not in the precomputed matrix β | |
| callers should fall back to haversine. | |
| """ | |
| return _load_road_distance_cache().get((kab_a_id, kab_b_id)) | |
| def distance_between(s: SupplyNode, d: DemandNode) -> float: | |
| """ | |
| Distance dalam km untuk Layer 1 viability + Layer 2 scoring. | |
| Prefer OSRM road precompute (sample_data/road_distance_jatim.csv); | |
| fallback ke haversine ketika pair tidak ada di cache (ekspansi | |
| luar Jatim, atau koordinat baru yang belum di-refresh). | |
| """ | |
| road = road_distance_km(s.kabupaten.id, d.kabupaten.id) | |
| if road is not None: | |
| return road | |
| return haversine_km( | |
| s.kabupaten.latitude, s.kabupaten.longitude, | |
| d.kabupaten.latitude, d.kabupaten.longitude, | |
| ) | |
| # ============================================================================= | |
| # LAYER 1 β HARD CONSTRAINTS | |
| # ============================================================================= | |
| class ConstraintReason(str): | |
| """Alasan kenapa pair di-reject di Layer 1 (untuk debugging/audit).""" | |
| DIFFERENT_COMMODITY = "DIFFERENT_COMMODITY" | |
| SUPPLY_BELOW_MIN = "SUPPLY_BELOW_MIN_VIABLE" | |
| DEMAND_BELOW_MIN = "DEMAND_BELOW_MIN_VIABLE" | |
| DISTANCE_EXCEEDS_MAX = "DISTANCE_EXCEEDS_MAX" | |
| SUPPLY_TOO_OLD = "SUPPLY_TOO_OLD_FOR_TRANSIT" | |
| SAME_KABUPATEN = "SAME_KABUPATEN" | |
| SUPPLY_UNREACHABLE = "SUPPLY_UNREACHABLE_DISASTER" | |
| PEMDA_OVERRIDE = "PEMDA_DO_NOT_EXPORT" | |
| BULOG_RESERVED = "BULOG_PROCUREMENT_PRIORITY" | |
| def _check_viable_pair( | |
| surplus: SupplyNode, | |
| deficit: DemandNode, | |
| logistics: LogisticsContext | None = None, | |
| allow_grade_substitution: bool = False, | |
| ) -> Tuple[bool, str, float]: | |
| """ | |
| Internal Layer 1 check β same semantics as is_viable_pair but also returns | |
| the haversine distance so callers can reuse it (avoid recomputing). | |
| Returns (viable, reason, distance_km). distance_km is 0.0 when the pair is | |
| rejected before the distance check fires. | |
| """ | |
| logistics = logistics or LogisticsContext() | |
| # Match komoditas β F1: izinkan grade substitution kalau di-opt-in | |
| if surplus.commodity.code != deficit.commodity.code: | |
| if allow_grade_substitution and grade_compatible( | |
| surplus.commodity.code, deficit.commodity.code | |
| ): | |
| pass # acceptable cross-grade match | |
| else: | |
| return False, ConstraintReason.DIFFERENT_COMMODITY, 0.0 | |
| # Hindari self-match | |
| if surplus.kabupaten.id == deficit.kabupaten.id: | |
| return False, ConstraintReason.SAME_KABUPATEN, 0.0 | |
| # Volume minimum (skenario A3) | |
| spec = surplus.commodity | |
| if surplus.volume_tons < spec.min_viable_tons: | |
| return False, ConstraintReason.SUPPLY_BELOW_MIN, 0.0 | |
| if deficit.volume_tons < spec.min_viable_tons: | |
| return False, ConstraintReason.DEMAND_BELOW_MIN, 0.0 | |
| # Skenario D4, D5 β kabupaten unreachable | |
| if surplus.kabupaten.emergency_mode == EmergencyMode.UNREACHABLE: | |
| return False, ConstraintReason.SUPPLY_UNREACHABLE, 0.0 | |
| if deficit.kabupaten.emergency_mode == EmergencyMode.UNREACHABLE: | |
| return False, ConstraintReason.SUPPLY_UNREACHABLE, 0.0 | |
| # Skenario E2 β Pemda override | |
| override_key = f"do_not_export_{spec.code}" | |
| if surplus.kabupaten.pemda_overrides.get(override_key, False): | |
| return False, ConstraintReason.PEMDA_OVERRIDE, 0.0 | |
| if surplus.kabupaten.emergency_mode == EmergencyMode.PEMDA_LOCKED: | |
| return False, ConstraintReason.PEMDA_OVERRIDE, 0.0 | |
| # Skenario E3 β Bulog priority di-handle penuh di engine.apply_bulog_split | |
| # (reserve 60% volume, sisanya tetap available). Layer 1 tidak perlu tahu | |
| # state-nya, jadi tidak ada lookup ke BULOG_PROCUREMENT_KAB di sini. | |
| # Skenario B2 β distance exceeds max | |
| # Dynamic adjustment untuk skenario E5 (BBM naik β MAX_DISTANCE shrink) | |
| effective_max_distance = spec.max_distance_km | |
| if logistics.bbm_change_pct > 0.10: | |
| # BBM naik >10% β long-haul jadi tidak ekonomis | |
| effective_max_distance *= (1 - min(0.20, logistics.bbm_change_pct * 0.5)) | |
| distance_km = distance_between(surplus, deficit) | |
| if distance_km > effective_max_distance: | |
| return False, ConstraintReason.DISTANCE_EXCEEDS_MAX, distance_km | |
| # Perishability β tidak akan sampai segar | |
| transit_days = math.ceil( | |
| distance_km / logistics.avg_speed_km_per_hour / logistics.transit_hours_per_day | |
| ) | |
| if surplus.harvest_age_days + transit_days > spec.max_fresh_age_days: | |
| return False, ConstraintReason.SUPPLY_TOO_OLD, distance_km | |
| return True, "", distance_km | |
| def is_viable_pair( | |
| surplus: SupplyNode, | |
| deficit: DemandNode, | |
| logistics: LogisticsContext | None = None, | |
| allow_grade_substitution: bool = False, | |
| ) -> Tuple[bool, str]: | |
| """ | |
| Layer 1: Cek apakah pair surplus-deficit lolos hard constraints. | |
| Return (viable, reason). reason kosong jika viable. | |
| Skenario yang di-cover (Section 5.5.5): | |
| B2 Long Distance β distance > MAX_DISTANCE β REJECT | |
| D4 Erupsi β supply UNREACHABLE β REJECT | |
| D5 Banjir Skala Besar β supply UNREACHABLE β REJECT | |
| E2 Pemda Override β do_not_export flag β REJECT | |
| E3 Bulog Priority β bulog_priority flag β REJECT | |
| A3 Volume Mismatch Drastis β supply >= MIN tetap PASS (di-flag di scoring) | |
| F1 Grade Substitution β premium β medium acceptable jika allow_grade_substitution | |
| """ | |
| viable, reason, _ = _check_viable_pair( | |
| surplus, deficit, logistics, allow_grade_substitution | |
| ) | |
| return viable, reason | |
| # Kab dengan Bulog procurement aktif (skenario E3) | |
| # Update via API Bulog Divre Jatim atau manual dari announcement | |
| BULOG_PROCUREMENT_KAB: Set[str] = set() # default empty, populated saat runtime | |
| def reset_bulog_procurement(): | |
| """Reset Bulog priority list (untuk testing).""" | |
| BULOG_PROCUREMENT_KAB.clear() | |
| def set_bulog_procurement(kab_ids: Iterable[str]): | |
| """Set kabupaten yang sedang ada Bulog procurement aktif.""" | |
| BULOG_PROCUREMENT_KAB.clear() | |
| BULOG_PROCUREMENT_KAB.update(kab_ids) | |
| # ============================================================================= | |
| # LAYER 1 β CANDIDATE GENERATION | |
| # ============================================================================= | |
| def generate_candidates( | |
| surplus_nodes: List[SupplyNode], | |
| deficit_nodes: List[DemandNode], | |
| logistics: LogisticsContext | None = None, | |
| top_k_per_surplus: int = 20, | |
| allow_grade_substitution: bool = False, | |
| ) -> List[Tuple[SupplyNode, DemandNode]]: | |
| """ | |
| Layer 1 main entrypoint: | |
| Filter pasangan viable lalu kerucutkan ke top-K per surplus berdasarkan jarak. | |
| Args: | |
| surplus_nodes: list semua kabupaten surplus | |
| deficit_nodes: list semua kabupaten deficit | |
| logistics: konteks logistik global (BBM, BBM baseline, dll) | |
| top_k_per_surplus: maksimum candidate per surplus (default 20) | |
| allow_grade_substitution: F1 β premium dapat memenuhi medium demand | |
| (default False supaya backward-compatible) | |
| Returns: | |
| List of (surplus, deficit) pairs yang viable, sudah di-prune top-K. | |
| Latency target: <50ms untuk 38 kab Γ 19 komoditas (~25k pasangan). | |
| """ | |
| logistics = logistics or LogisticsContext() | |
| # Group deficits by commodity untuk efisiensi (untuk substitution: juga | |
| # pertimbangkan deficit dengan grade yang lebih rendah) | |
| deficits_by_commodity: dict[str, List[DemandNode]] = {} | |
| for d in deficit_nodes: | |
| deficits_by_commodity.setdefault(d.commodity.code, []).append(d) | |
| candidates: List[Tuple[SupplyNode, DemandNode]] = [] | |
| for s in surplus_nodes: | |
| # Cari deficit dengan komoditas sama | |
| candidate_deficits = list(deficits_by_commodity.get(s.commodity.code, [])) | |
| # F1: tambah deficit dengan komoditas yang dapat di-substitute oleh surplus ini | |
| if allow_grade_substitution: | |
| for sub_code in GRADE_SUBSTITUTION.get(s.commodity.code, []): | |
| candidate_deficits.extend(deficits_by_commodity.get(sub_code, [])) | |
| viable_for_s: List[Tuple[float, DemandNode]] = [] | |
| for d in candidate_deficits: | |
| # _check_viable_pair returns the haversine distance it already | |
| # computed during the B2/perishability checks β reusing it here | |
| # avoids a second haversine call per viable pair (~30% Layer 1 | |
| # speedup per AUDIT_v10.md). | |
| ok, _reason, dist = _check_viable_pair( | |
| s, d, logistics, allow_grade_substitution=allow_grade_substitution | |
| ) | |
| if ok: | |
| viable_for_s.append((dist, d)) | |
| # ambil top-K terdekat (proxy untuk priority β Layer 2 yang akan re-rank) | |
| viable_for_s.sort(key=lambda x: x[0]) | |
| for _dist, d in viable_for_s[:top_k_per_surplus]: | |
| candidates.append((s, d)) | |
| return candidates | |