feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
@@ -1,11 +1,19 @@
|
||||
"""Tests for industrial AVM rent estimation endpoint."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.models.avm_industrial import IndustrialAVMRequest
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
RIDGE_MODEL_DIR = REPO_ROOT / "models"
|
||||
RIDGE_ARTIFACT = RIDGE_MODEL_DIR / "avm_industrial_park_ridge_v1.pkl"
|
||||
|
||||
# ── Minimal valid request payload ───────────────────────────────
|
||||
|
||||
_PREDICT_PAYLOAD = {
|
||||
@@ -178,3 +186,99 @@ def test_predict_industrial_invalid_occupancy():
|
||||
json={**_PREDICT_PAYLOAD, "park_occupancy_rate": 1.5},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# ── Ridge v1 artifact tests (TEC-2768) ───────────────────────────────
|
||||
|
||||
_RIDGE_REQ = IndustrialAVMRequest(
|
||||
province="Bình Dương",
|
||||
region="south",
|
||||
park_occupancy_rate=0.85,
|
||||
park_area_ha=500,
|
||||
park_age_years=10,
|
||||
distance_to_port_km=25,
|
||||
distance_to_airport_km=20,
|
||||
distance_to_highway_km=2,
|
||||
property_type="ready_built_factory",
|
||||
area_m2=5000,
|
||||
ceiling_height_m=10,
|
||||
floor_load_ton_m2=3.0,
|
||||
power_capacity_kva=1500,
|
||||
building_coverage=0.55,
|
||||
loading_docks=4,
|
||||
zoning="general_industrial",
|
||||
industry_demand_index=0.7,
|
||||
fdi_province_musd=4800,
|
||||
labor_cost_province_vnd=8_500_000,
|
||||
logistics_connectivity_score=0.85,
|
||||
)
|
||||
|
||||
|
||||
def _fresh_service_with_model_dir(model_dir: Path):
|
||||
"""Build a fresh service instance pointed at `model_dir`.
|
||||
|
||||
Needed because `industrial_avm_service` is a module-level singleton whose
|
||||
backend is decided at import time.
|
||||
"""
|
||||
from app.config import settings
|
||||
from app.services.avm_industrial_service import IndustrialAVMService
|
||||
|
||||
original = settings.model_path
|
||||
settings.model_path = str(model_dir)
|
||||
try:
|
||||
return IndustrialAVMService()
|
||||
finally:
|
||||
settings.model_path = original
|
||||
|
||||
|
||||
@pytest.mark.skipif(not RIDGE_ARTIFACT.exists(), reason="ridge artifact not built")
|
||||
def test_predict_uses_ridge_when_artifact_present():
|
||||
svc = _fresh_service_with_model_dir(RIDGE_MODEL_DIR)
|
||||
assert svc._backend == "ridge"
|
||||
assert svc._model_version == "ridge-industrial-v1"
|
||||
|
||||
resp = svc.predict(_RIDGE_REQ)
|
||||
assert resp.model_version == "ridge-industrial-v1"
|
||||
assert resp.estimated_rent_usd_m2 > 0
|
||||
assert resp.rent_range_low_usd_m2 <= resp.estimated_rent_usd_m2
|
||||
assert resp.rent_range_high_usd_m2 >= resp.estimated_rent_usd_m2
|
||||
# Conformal band must have strictly positive width.
|
||||
assert resp.rent_range_high_usd_m2 > resp.rent_range_low_usd_m2
|
||||
# Confidence should match the stored LOO coverage (≥ 0.75 acceptance).
|
||||
assert resp.confidence >= 0.75
|
||||
|
||||
|
||||
def test_predict_falls_back_to_heuristic_when_artifact_absent(tmp_path: Path):
|
||||
svc = _fresh_service_with_model_dir(tmp_path) # empty dir → no artifacts
|
||||
assert svc._backend == "heuristic"
|
||||
resp = svc.predict(_RIDGE_REQ)
|
||||
assert resp.model_version == "heuristic-v1"
|
||||
assert resp.estimated_rent_usd_m2 > 0
|
||||
|
||||
|
||||
@pytest.mark.skipif(not RIDGE_ARTIFACT.exists(), reason="ridge artifact not built")
|
||||
def test_ridge_monotonic_occupancy():
|
||||
svc = _fresh_service_with_model_dir(RIDGE_MODEL_DIR)
|
||||
low = svc.predict(_RIDGE_REQ.model_copy(update={"park_occupancy_rate": 0.30}))
|
||||
high = svc.predict(_RIDGE_REQ.model_copy(update={"park_occupancy_rate": 0.95}))
|
||||
assert high.estimated_rent_usd_m2 >= low.estimated_rent_usd_m2
|
||||
|
||||
|
||||
@pytest.mark.skipif(not RIDGE_ARTIFACT.exists(), reason="ridge artifact not built")
|
||||
def test_ridge_land_head_conversion():
|
||||
"""industrial_land requests must convert annual → monthly USD/m²."""
|
||||
svc = _fresh_service_with_model_dir(RIDGE_MODEL_DIR)
|
||||
resp = svc.predict(_RIDGE_REQ.model_copy(update={"property_type": "industrial_land"}))
|
||||
# annual_rent_usd_m2 ≈ 12 × estimated_rent_usd_m2 (with rounding tolerance)
|
||||
assert resp.estimated_rent_usd_m2 > 0
|
||||
assert abs(resp.annual_rent_usd_m2 - resp.estimated_rent_usd_m2 * 12) < 0.5
|
||||
|
||||
|
||||
@pytest.mark.skipif(not RIDGE_ARTIFACT.exists(), reason="ridge artifact not built")
|
||||
def test_ridge_warehouse_head_different_from_factory():
|
||||
"""Warehouse and factory requests must route to different ridge heads."""
|
||||
svc = _fresh_service_with_model_dir(RIDGE_MODEL_DIR)
|
||||
rbf = svc.predict(_RIDGE_REQ.model_copy(update={"property_type": "ready_built_factory"}))
|
||||
rbw = svc.predict(_RIDGE_REQ.model_copy(update={"property_type": "warehouse"}))
|
||||
# Training data consistently shows RBF > RBW rents — heads should reflect that.
|
||||
assert rbf.estimated_rent_usd_m2 != rbw.estimated_rent_usd_m2
|
||||
|
||||
Reference in New Issue
Block a user