agriflow-api / tests /test_layer2_scoring.py
masterAAA123's picture
Space deploy v5: orphan snapshot of main, zero binary files
b81a86b
Raw
History Blame Contribute Delete
10.9 kB
"""
Unit tests Layer 2 (Multi-Objective Scoring).
Section 5.5.4 — bobot Distance 22% / Volume 22% / Price 22% / Perishability 18% / Climate 16%
"""
import pytest
from matching_engine.scoring import (
DEFAULT_WEIGHTS, RAMADAN_WEIGHTS, IMPORT_POLICY_WEIGHTS,
climate_score, compute_score, distance_score,
estimate_logistics_cost_per_kg, perishability_score,
price_score, volume_score,
)
from matching_engine.models import LogisticsContext, WeatherForecast
# =============================================================================
# DISTANCE SCORE
# =============================================================================
class TestDistanceScore:
def test_short_distance_high_score(self, surabaya, sidoarjo, cabai_merah, make_supply, make_demand):
s = make_supply(sidoarjo, cabai_merah)
d = make_demand(surabaya, cabai_merah)
score, dist = distance_score(s, d)
assert dist < 30 # Sidoarjo-Surabaya ~22 km
assert score > 0.85
def test_long_distance_low_score(self, pacitan, banyuwangi, beras_premium, make_supply, make_demand):
# Pacitan (barat) - Banyuwangi (timur) ~400km, max_distance beras = 800km
s = make_supply(pacitan, beras_premium)
d = make_demand(banyuwangi, beras_premium)
score, dist = distance_score(s, d)
assert dist > 350
assert score < 0.6 # ~0.5 expected
def test_score_is_zero_at_max_distance(self, surabaya, banyuwangi, cabai_merah, make_supply, make_demand):
s = make_supply(banyuwangi, cabai_merah) # ~280km > max 200km
d = make_demand(surabaya, cabai_merah)
score, _ = distance_score(s, d)
assert score == 0.0
# =============================================================================
# VOLUME SCORE
# =============================================================================
class TestVolumeScore:
def test_perfect_match(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
s = make_supply(kediri_kab, cabai_merah, volume=50)
d = make_demand(surabaya, cabai_merah, volume=50)
assert volume_score(s, d) == 1.0
def test_drastic_mismatch_low_score(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
# Skenario A3: 5 ton supply vs 100 ton demand
s = make_supply(kediri_kab, cabai_merah, volume=5)
d = make_demand(surabaya, cabai_merah, volume=100)
score = volume_score(s, d)
assert score == 0.05 # 5/100
def test_supply_larger_than_demand_full_coverage(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
# v11 fix: surplus 200t > demand 50t → demand fully covered (50/50 = 1.0).
# Excess surplus (150t) akan di-split ke deficit lain di Layer 3 greedy.
# Old semantics (min/max → 0.25) punished big-producer matches —
# not aligned dengan Indonesian supply chain reality.
s = make_supply(kediri_kab, cabai_merah, volume=200)
d = make_demand(surabaya, cabai_merah, volume=50)
assert volume_score(s, d) == pytest.approx(1.0)
def test_supply_partial_coverage(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
# Supply < demand → partial coverage.
# 30t supply / 50t demand = 0.6 (60% of demand satisfied by this source).
s = make_supply(kediri_kab, cabai_merah, volume=30)
d = make_demand(surabaya, cabai_merah, volume=50)
assert volume_score(s, d) == pytest.approx(0.6)
# =============================================================================
# PRICE SCORE & LOGISTICS COST
# =============================================================================
class TestPriceScore:
def test_arbitrage_positive_high_score(self, surabaya, kediri_kab, cabai_merah,
make_supply, make_demand, logistics_normal):
# 30 → 60 ribu (selisih 100%) jarak ~70km, harus score tinggi
s = make_supply(kediri_kab, cabai_merah, price=30000)
d = make_demand(surabaya, cabai_merah, price=60000)
score = price_score(s, d, distance_km=70, logistics=logistics_normal)
assert score > 0.9 # arbitrage net >50%
def test_arbitrage_negative_zero_score(self, surabaya, kediri_kab, cabai_merah,
make_supply, make_demand, logistics_normal):
# Demand < supply → lossy
s = make_supply(kediri_kab, cabai_merah, price=50000)
d = make_demand(surabaya, cabai_merah, price=40000)
score = price_score(s, d, distance_km=70, logistics=logistics_normal)
assert score == 0.0
def test_logistics_cost_per_kg_increases_with_distance(self, logistics_normal):
cost_short = estimate_logistics_cost_per_kg(50, logistics_normal)
cost_long = estimate_logistics_cost_per_kg(500, logistics_normal)
assert cost_long > cost_short
def test_bbm_naik_increases_cost(self, logistics_normal, logistics_bbm_naik_20pct):
# Skenario E5
cost_normal = estimate_logistics_cost_per_kg(200, logistics_normal)
cost_bbm_naik = estimate_logistics_cost_per_kg(200, logistics_bbm_naik_20pct)
assert cost_bbm_naik > cost_normal
# =============================================================================
# PERISHABILITY SCORE
# =============================================================================
class TestPerishabilityScore:
def test_fresh_short_transit_high_score(self, surabaya, kediri_kab, cabai_merah,
make_supply, make_demand, logistics_normal):
# cabai max_fresh=5 hari, age=0, transit jarak pendek → margin ~4.9 hari
# Score = min(1.0, 4.9/5.0) = ~0.98
s = make_supply(kediri_kab, cabai_merah, age=0)
d = make_demand(surabaya, cabai_merah)
score = perishability_score(s, d, distance_km=50, logistics=logistics_normal)
assert score > 0.9 # near maksimum
def test_too_old_zero_score(self, surabaya, kediri_kab, cabai_merah,
make_supply, make_demand, logistics_normal):
# cabai max_fresh = 5 hari, age=4, transit ~1 hari, sisa 0 hari
s = make_supply(kediri_kab, cabai_merah, age=4)
d = make_demand(surabaya, cabai_merah)
score = perishability_score(s, d, distance_km=70, logistics=logistics_normal)
assert score == 0.0 # margin < 1 hari
def test_beras_long_shelf_always_high(self, surabaya, kediri_kab, beras_premium,
make_supply, make_demand, logistics_normal):
s = make_supply(kediri_kab, beras_premium, age=0)
d = make_demand(surabaya, beras_premium)
score = perishability_score(s, d, distance_km=200, logistics=logistics_normal)
assert score == 1.0
# =============================================================================
# CLIMATE SCORE
# =============================================================================
class TestClimateScore:
def test_no_forecast_neutral(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
s = make_supply(kediri_kab, cabai_merah)
d = make_demand(surabaya, cabai_merah)
assert climate_score(s, d, weather=None) == 0.7
def test_clear_weather_full_score(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
s = make_supply(kediri_kab, cabai_merah)
d = make_demand(surabaya, cabai_merah)
wf = WeatherForecast(origin_kab_id="3506", dest_kab_id="3578", max_rain_mm=5.0)
assert climate_score(s, d, weather=wf) == 1.0
def test_heavy_rain_low_score(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
# Skenario D1
s = make_supply(kediri_kab, cabai_merah)
d = make_demand(surabaya, cabai_merah)
wf = WeatherForecast(origin_kab_id="3506", dest_kab_id="3578", max_rain_mm=75.0)
assert climate_score(s, d, weather=wf) == 0.3
def test_medium_rain_mid_score(self, surabaya, kediri_kab, cabai_merah, make_supply, make_demand):
s = make_supply(kediri_kab, cabai_merah)
d = make_demand(surabaya, cabai_merah)
wf = WeatherForecast(origin_kab_id="3506", dest_kab_id="3578", max_rain_mm=30.0)
assert climate_score(s, d, weather=wf) == 0.6
# =============================================================================
# COMPUTE SCORE — full weighted total
# =============================================================================
class TestComputeScore:
def test_default_weights_sum_to_one(self):
assert sum(DEFAULT_WEIGHTS.values()) == pytest.approx(1.0)
def test_ramadan_weights_sum_to_one(self):
assert sum(RAMADAN_WEIGHTS.values()) == pytest.approx(1.0)
def test_import_policy_weights_sum_to_one(self):
assert sum(IMPORT_POLICY_WEIGHTS.values()) == pytest.approx(1.0)
def test_default_weights_match_section_5_5_4(self):
# Bobot Section 5.5.4: 22/22/22/18/16
assert DEFAULT_WEIGHTS["distance"] == 0.22
assert DEFAULT_WEIGHTS["volume"] == 0.22
assert DEFAULT_WEIGHTS["price"] == 0.22
assert DEFAULT_WEIGHTS["perishability"] == 0.18
assert DEFAULT_WEIGHTS["climate"] == 0.16
def test_compute_score_returns_breakdown_and_total(self, surabaya, kediri_kab, cabai_merah,
make_supply, make_demand, logistics_normal):
s = make_supply(kediri_kab, cabai_merah, volume=50, price=30000, age=0)
d = make_demand(surabaya, cabai_merah, volume=50, price=60000)
breakdown, base, dist = compute_score(s, d, logistics=logistics_normal)
# All 5 dims should be > 0 for a healthy match
assert breakdown.distance > 0
assert breakdown.volume == 1.0
assert breakdown.price > 0.5
assert breakdown.perishability > 0.9 # cabai 5d shelf, near max
assert 0 < base <= 100
def test_ramadan_weights_increase_perishability_importance(self, surabaya, kediri_kab,
cabai_merah, make_supply, make_demand,
logistics_normal):
# Skenario C1: bobot perishability naik dari 0.18 ke 0.22
s = make_supply(kediri_kab, cabai_merah, age=2)
d = make_demand(surabaya, cabai_merah)
_, base_default, _ = compute_score(s, d, logistics=logistics_normal,
weights=DEFAULT_WEIGHTS)
_, base_ramadan, _ = compute_score(s, d, logistics=logistics_normal,
weights=RAMADAN_WEIGHTS)
# Score harus berbeda (perishability lebih dominan)
assert base_default != base_ramadan