Spaces:
Running
Running
| """ | |
| AgriFlow Matching Engine — Data Models | |
| ======================================= | |
| Dataclasses untuk semua entitas yang dipakai matching engine. | |
| Designed agar serializable (untuk API response) dan immutable di mana mungkin. | |
| Author: AgriFlow Team | |
| Version: 9.0 | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, field, asdict | |
| from datetime import datetime | |
| from enum import Enum | |
| from typing import Dict, List, Optional, Any | |
| # ============================================================================= | |
| # ENUMS | |
| # ============================================================================= | |
| class Tier(str, Enum): | |
| """Confidence tier berdasarkan kualitas data kabupaten.""" | |
| HIGH = "TIER_1_HIGH" # 8 kota IHK Jatim (PIHPS daily) | |
| MEDIUM = "TIER_2_MEDIUM" # 30 kab non-IHK (Bapanas weekly + estimasi) | |
| class Confidence(str, Enum): | |
| """Confidence label untuk setiap match output.""" | |
| HIGH = "HIGH" | |
| MEDIUM = "MEDIUM" | |
| LOW = "LOW" | |
| class EmergencyMode(str, Enum): | |
| """Status emergency/disaster yang aktif untuk satu kabupaten.""" | |
| NORMAL = "NORMAL" | |
| UNREACHABLE = "UNREACHABLE" # erupsi, banjir besar | |
| HUMANITARIAN = "HUMANITARIAN" # daerah bencana butuh prioritas demand | |
| PEMDA_LOCKED = "PEMDA_LOCKED" # do_not_export oleh Pemda | |
| class DemandSegment(str, Enum): | |
| """Segmentasi demand-side untuk skenario F2 (HORECA vs Retail). | |
| Sama komoditas, beda customer → beda grade preferensi + price point. | |
| Engine tidak merge HORECA dan RETAIL demand di kab yang sama; | |
| setiap segment di-match independen. | |
| """ | |
| RETAIL = "RETAIL" # household / pasar tradisional (default) | |
| HORECA = "HORECA" # hotel / restoran / catering — volume tinggi, grade Medium | |
| GOVERNMENT = "GOVERNMENT" # Bulog procurement, sekolah, militer, kantor | |
| INDUSTRIAL = "INDUSTRIAL" # pabrik (tepung → mie, kedelai → tahu/tempe) | |
| # ============================================================================= | |
| # CORE ENTITIES | |
| # ============================================================================= | |
| class Kabupaten: | |
| """Representasi kabupaten/kota Jawa Timur.""" | |
| id: str # e.g. "3578" (kode wilayah BPS) | |
| nama: str # e.g. "Surabaya" | |
| latitude: float | |
| longitude: float | |
| ipm: float # IPM 2024 BPS | |
| tier: Tier | |
| population: int = 0 # untuk konversi konsumsi | |
| emergency_mode: EmergencyMode = EmergencyMode.NORMAL | |
| pemda_overrides: Dict[str, bool] = field(default_factory=dict) | |
| # contoh override: {"do_not_export_cabai_merah": True} | |
| def is_tier1(self) -> bool: | |
| return self.tier == Tier.HIGH | |
| def equity_multiplier(self) -> float: | |
| """ | |
| Equity multiplier berdasarkan IPM (Section 5.5.4 Step 3a). | |
| Source of truth: matching_engine.allocation.equity_multiplier_value. | |
| Threshold lihat docstring fungsi tersebut (kalibrasi BPS 2024 Jatim). | |
| """ | |
| # Lazy import — hindari circular dependency dengan allocation.py. | |
| from .allocation import equity_multiplier_value | |
| return equity_multiplier_value(self.ipm) | |
| class Commodity: | |
| """Spesifikasi komoditas pangan (constraint per komoditas).""" | |
| code: str # e.g. "cabai_merah" | |
| nama: str # e.g. "Cabai Merah Besar" | |
| max_distance_km: float # batas jarak transit viable | |
| min_viable_tons: float # volume minimum agar match worth it | |
| max_fresh_age_days: int # shelf life sejak panen | |
| bulog_priority: bool = False # True jika Bulog procurement aktif | |
| is_imported: bool = False # True jika ada kebijakan import aktif | |
| class SupplyNode: | |
| """Surplus dari satu kabupaten untuk satu komoditas pada hari tertentu.""" | |
| kabupaten: Kabupaten | |
| commodity: Commodity | |
| volume_tons: float | |
| price_per_kg: float | |
| harvest_age_days: int = 0 # 0 = baru panen | |
| timestamp: datetime = field(default_factory=datetime.now) | |
| data_source: str = "PIHPS" # "PIHPS" | "BAPANAS" | "ESTIMATED" | |
| def remaining_shelf_days(self) -> int: | |
| return max(0, self.commodity.max_fresh_age_days - self.harvest_age_days) | |
| class DemandNode: | |
| """Defisit/kebutuhan dari satu kabupaten untuk satu komoditas. | |
| Skenario F2: segment != RETAIL menandai demand-side segmentation | |
| (HORECA, GOVERNMENT, INDUSTRIAL). Engine tidak merge demand antar | |
| segment untuk komoditas + kab yang sama — Pemda dapat melihat tiap | |
| segment dipenuhi oleh surplus berbeda dengan grade berbeda. | |
| """ | |
| kabupaten: Kabupaten | |
| commodity: Commodity | |
| volume_tons: float | |
| price_per_kg: float | |
| timestamp: datetime = field(default_factory=datetime.now) | |
| data_source: str = "PIHPS" | |
| segment: DemandSegment = DemandSegment.RETAIL # F2 — backwards compat default | |
| class WeatherForecast: | |
| """Forecast cuaca pada rute selama transit window.""" | |
| origin_kab_id: str | |
| dest_kab_id: str | |
| max_rain_mm: float # mm hujan maksimum di rute selama transit | |
| transit_window_days: int = 1 | |
| source: str = "BMKG" # "BMKG" | "OPEN_METEO" | |
| class RouteBlackout: | |
| """Skenario D6 — rute tidak tersedia karena mudik / demo / maintenance. | |
| Origin/dest wildcard: gunakan "*" untuk match-any (mis. semua rute via | |
| toll Cikampek). start_date / end_date inclusive. | |
| """ | |
| origin_kab_id: str # "*" untuk wildcard | |
| dest_kab_id: str # "*" untuk wildcard | |
| start_date: datetime | |
| end_date: datetime | |
| reason: str = "" # "MUDIK_H1_IDUL_FITRI" | "DEMO_TRANS_JAWA" | "SURAMADU_MAINT" | |
| def is_active(self, reference_date: datetime) -> bool: | |
| return self.start_date <= reference_date <= self.end_date | |
| def matches_route(self, origin_id: str, dest_id: str) -> bool: | |
| ok_origin = self.origin_kab_id == "*" or self.origin_kab_id == origin_id | |
| ok_dest = self.dest_kab_id == "*" or self.dest_kab_id == dest_id | |
| return ok_origin and ok_dest | |
| class LogisticsContext: | |
| """Variabel logistik global yang mempengaruhi semua match.""" | |
| bbm_price_idr_per_liter: float = 10000.0 # subsidi | |
| bbm_price_baseline: float = 10000.0 # untuk hitung delta | |
| truck_consumption_km_per_liter: float = 4.0 | |
| avg_speed_km_per_hour: float = 60.0 | |
| transit_hours_per_day: float = 8.0 | |
| is_ramadan_proximity: bool = False # H-14 sebelum Idul Fitri | |
| is_post_harvest_season: bool = False # Maret-April padi | |
| def bbm_change_pct(self) -> float: | |
| if self.bbm_price_baseline == 0: | |
| return 0.0 | |
| return (self.bbm_price_idr_per_liter - self.bbm_price_baseline) / self.bbm_price_baseline | |
| # ============================================================================= | |
| # OUTPUT TYPES | |
| # ============================================================================= | |
| class ScoreBreakdown: | |
| """Pemecahan 5-dimensi scoring (Section 5.5.4 Layer 2).""" | |
| distance: float = 0.0 # 0-1 | |
| volume: float = 0.0 # 0-1 | |
| price: float = 0.0 # 0-1 | |
| perishability: float = 0.0 # 0-1 | |
| climate: float = 0.0 # 0-1 | |
| def weighted_total(self) -> float: | |
| """Bobot Section 5.5.4: 22/22/22/18/16.""" | |
| return ( | |
| 0.22 * self.distance | |
| + 0.22 * self.volume | |
| + 0.22 * self.price | |
| + 0.18 * self.perishability | |
| + 0.16 * self.climate | |
| ) * 100 # skala 0-100 | |
| class MatchResult: | |
| """Hasil satu pasangan surplus → deficit. | |
| v11: final_score = base_score × equity_multiplier × segment_multiplier. | |
| segment_multiplier default 1.00 (RETAIL baseline) — backward-compat dengan | |
| callers yang tidak set demand.segment. | |
| """ | |
| surplus: SupplyNode | |
| deficit: DemandNode | |
| matched_volume_tons: float | |
| distance_km: float | |
| base_score: float # 0-100, dari 5-dim weighted | |
| equity_multiplier: float # 1.00, 1.05, 1.15, atau 1.30 | |
| final_score: float # base_score × equity_multiplier × segment_multiplier | |
| confidence: Confidence | |
| breakdown: ScoreBreakdown | |
| flags: List[str] = field(default_factory=list) | |
| # contoh flags: ["RAMADAN_SPIKE", "BULOG_PRIORITY", "EQUITY_BOOST_30", | |
| # "SEGMENT_HORECA_BULK_BONUS"] | |
| notes: str = "" | |
| segment_multiplier: float = 1.00 # v11 — segment-aware adjustment | |
| def to_dict(self) -> Dict[str, Any]: | |
| """Untuk API response — semua nested object di-flatten.""" | |
| d = asdict(self) | |
| # convert enum & datetime ke string | |
| d["confidence"] = self.confidence.value | |
| d["surplus"]["kabupaten"]["tier"] = self.surplus.kabupaten.tier.value | |
| d["surplus"]["kabupaten"]["emergency_mode"] = self.surplus.kabupaten.emergency_mode.value | |
| d["deficit"]["kabupaten"]["tier"] = self.deficit.kabupaten.tier.value | |
| d["deficit"]["kabupaten"]["emergency_mode"] = self.deficit.kabupaten.emergency_mode.value | |
| d["surplus"]["timestamp"] = self.surplus.timestamp.isoformat() | |
| d["deficit"]["timestamp"] = self.deficit.timestamp.isoformat() | |
| return d | |
| class MatchingReport: | |
| """Output keseluruhan dari satu run matching engine.""" | |
| matches: List[MatchResult] | |
| unmatched_surplus: List[SupplyNode] = field(default_factory=list) | |
| unmatched_deficit: List[DemandNode] = field(default_factory=list) | |
| external_opportunities: List[str] = field(default_factory=list) | |
| # contoh: ["Jakarta defisit cabai 800t — suggest ekspor"] | |
| warnings: List[str] = field(default_factory=list) | |
| run_metadata: Dict[str, Any] = field(default_factory=dict) | |
| # contoh: {"latency_ms": 342, "tier1_count": 5, "tier2_count": 13} | |