masterAAA123's picture
Space deploy v5: orphan snapshot of main, zero binary files
b81a86b
Raw
History Blame Contribute Delete
10 kB
"""
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}