agriflow-api / matching_engine /constraints.py
masterAAA123's picture
Space deploy v5: orphan snapshot of main, zero binary files
b81a86b
Raw
History Blame Contribute Delete
17.8 kB
"""
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