diff --git a/libs/ai-services/app/models/avm_industrial.py b/libs/ai-services/app/models/avm_industrial.py index c65523c..1bf33e2 100644 --- a/libs/ai-services/app/models/avm_industrial.py +++ b/libs/ai-services/app/models/avm_industrial.py @@ -37,6 +37,20 @@ class IndustrialAVMRequest(BaseModel): power_capacity_kva: float = Field( 0.0, ge=0, description="Allocated power capacity in kVA" ) + building_coverage: float = Field( + 0.0, + ge=0, + le=1, + description="Building coverage ratio — footprint area / lot area (0-1)", + ) + loading_docks: int = Field( + 0, ge=0, description="Number of loading docks / bays" + ) + zoning: str = Field( + "general_industrial", + description="Industrial zoning category: general_industrial, heavy_industrial, " + "light_industrial, logistics, free_trade_zone, high_tech", + ) industry_demand_index: float = Field( 0.5, ge=0, le=1, description="Local industry demand index (0-1)" ) diff --git a/libs/ai-services/app/services/avm_industrial_service.py b/libs/ai-services/app/services/avm_industrial_service.py index d4b173b..869454c 100644 --- a/libs/ai-services/app/services/avm_industrial_service.py +++ b/libs/ai-services/app/services/avm_industrial_service.py @@ -34,6 +34,9 @@ INDUSTRIAL_FEATURE_NAMES = [ "ceiling_height_m", "floor_load_ton_m2", "power_capacity_kva", + "building_coverage", + "loading_docks", + "zoning_encoded", "industry_demand_index", "fdi_province_musd", "labor_cost_province_vnd", @@ -56,6 +59,15 @@ PROPERTY_TYPE_MAP = { "office_in_park": 5, } +ZONING_MAP = { + "general_industrial": 0, + "heavy_industrial": 1, + "light_industrial": 2, + "logistics": 3, + "free_trade_zone": 4, + "high_tech": 5, +} + # ── Province-level rent baselines (USD/m²/month) ──────────────── # Based on Vietnamese industrial real estate market data PROVINCE_BASELINE: dict[str, float] = { @@ -113,6 +125,9 @@ def _encode_features(req: IndustrialAVMRequest) -> np.ndarray: req.ceiling_height_m, req.floor_load_ton_m2, req.power_capacity_kva, + req.building_coverage, + req.loading_docks, + ZONING_MAP.get(req.zoning.lower(), 0), req.industry_demand_index, req.fdi_province_musd, req.labor_cost_province_vnd, @@ -253,6 +268,22 @@ class IndustrialAVMService: floor_load_adj = 1.0 + max(0.0, (req.floor_load_ton_m2 - 2.0) * 0.03) power_adj = 1.0 + min(0.10, req.power_capacity_kva / 5000 * 0.10) + # Building coverage — higher coverage = more usable space = premium + coverage_adj = 1.0 + max(0.0, (req.building_coverage - 0.4) * 0.15) + + # Loading docks — each dock adds a small premium, diminishing returns + docks_adj = 1.0 + min(0.12, req.loading_docks * 0.02) + + # Zoning premium — specialized zones command higher rents + zoning_mult = { + "general_industrial": 1.00, + "heavy_industrial": 0.95, + "light_industrial": 1.05, + "logistics": 1.10, + "free_trade_zone": 1.20, + "high_tech": 1.25, + }.get(req.zoning.lower(), 1.0) + # Economic indicators demand_adj = 1.0 + (req.industry_demand_index - 0.5) * 0.25 fdi_adj = 1.0 + min(0.15, req.fdi_province_musd / 5000 * 0.15) @@ -280,6 +311,9 @@ class IndustrialAVMService: * ceiling_adj * floor_load_adj * power_adj + * coverage_adj + * docks_adj + * zoning_mult * demand_adj * fdi_adj * labor_adj @@ -291,14 +325,17 @@ class IndustrialAVMService: # Heuristic feature importance drivers = [ - FeatureImportance(feature="province_baseline", importance=0.20), - FeatureImportance(feature="property_type", importance=0.15), - FeatureImportance(feature="park_occupancy_rate", importance=0.12), - FeatureImportance(feature="logistics_connectivity_score", importance=0.10), - FeatureImportance(feature="industry_demand_index", importance=0.10), - FeatureImportance(feature="fdi_province_musd", importance=0.08), - FeatureImportance(feature="distance_to_port_km", importance=0.07), - FeatureImportance(feature="area_m2", importance=0.06), + FeatureImportance(feature="province_baseline", importance=0.16), + FeatureImportance(feature="property_type", importance=0.12), + FeatureImportance(feature="zoning", importance=0.11), + FeatureImportance(feature="park_occupancy_rate", importance=0.10), + FeatureImportance(feature="logistics_connectivity_score", importance=0.09), + FeatureImportance(feature="industry_demand_index", importance=0.09), + FeatureImportance(feature="building_coverage", importance=0.07), + FeatureImportance(feature="loading_docks", importance=0.06), + FeatureImportance(feature="fdi_province_musd", importance=0.06), + FeatureImportance(feature="distance_to_port_km", importance=0.05), + FeatureImportance(feature="area_m2", importance=0.05), ] return IndustrialAVMResponse( diff --git a/libs/ai-services/tests/test_avm_industrial.py b/libs/ai-services/tests/test_avm_industrial.py index 4e69483..960bf8f 100644 --- a/libs/ai-services/tests/test_avm_industrial.py +++ b/libs/ai-services/tests/test_avm_industrial.py @@ -109,6 +109,62 @@ def test_predict_industrial_total_rent(): assert abs(resp["total_monthly_rent_usd"] - expected_total) < 1.0 +def test_predict_industrial_free_trade_zone_premium(): + """Free-trade-zone zoning should command higher rent than general_industrial.""" + general = client.post( + "/avm/industrial/predict", + json={**_PREDICT_PAYLOAD, "zoning": "general_industrial"}, + ).json() + ftz = client.post( + "/avm/industrial/predict", + json={**_PREDICT_PAYLOAD, "zoning": "free_trade_zone"}, + ).json() + + assert ftz["estimated_rent_usd_m2"] > general["estimated_rent_usd_m2"] + + +def test_predict_industrial_high_tech_zone_premium(): + """High-tech zoning should command higher rent than general_industrial.""" + general = client.post( + "/avm/industrial/predict", + json={**_PREDICT_PAYLOAD, "zoning": "general_industrial"}, + ).json() + ht = client.post( + "/avm/industrial/predict", + json={**_PREDICT_PAYLOAD, "zoning": "high_tech"}, + ).json() + + assert ht["estimated_rent_usd_m2"] > general["estimated_rent_usd_m2"] + + +def test_predict_industrial_loading_docks_premium(): + """More loading docks should increase rent.""" + no_docks = client.post( + "/avm/industrial/predict", + json={**_PREDICT_PAYLOAD, "loading_docks": 0}, + ).json() + many_docks = client.post( + "/avm/industrial/predict", + json={**_PREDICT_PAYLOAD, "loading_docks": 6}, + ).json() + + assert many_docks["estimated_rent_usd_m2"] > no_docks["estimated_rent_usd_m2"] + + +def test_predict_industrial_building_coverage_premium(): + """Higher building coverage should increase rent.""" + low_cov = client.post( + "/avm/industrial/predict", + json={**_PREDICT_PAYLOAD, "building_coverage": 0.3}, + ).json() + high_cov = client.post( + "/avm/industrial/predict", + json={**_PREDICT_PAYLOAD, "building_coverage": 0.7}, + ).json() + + assert high_cov["estimated_rent_usd_m2"] > low_cov["estimated_rent_usd_m2"] + + def test_predict_industrial_validation_error(): """Missing required fields should return 422.""" resp = client.post("/avm/industrial/predict", json={"area_m2": 5000})