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}