Spaces:
Sleeping
Sleeping
File size: 9,998 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 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 | """
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
# =============================================================================
@dataclass
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}
@property
def is_tier1(self) -> bool:
return self.tier == Tier.HIGH
@property
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)
@dataclass
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
@dataclass
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"
@property
def remaining_shelf_days(self) -> int:
return max(0, self.commodity.max_fresh_age_days - self.harvest_age_days)
@dataclass
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
@dataclass
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"
@dataclass
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
@dataclass
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
@property
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
# =============================================================================
@dataclass
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
@dataclass
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
@dataclass
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}
|