feat(ai-services): add building_coverage, loading_docks, zoning to industrial AVM
Completes the industrial-specific feature set required for AVM industrial valuation. Adds heuristic adjustments for all three new features and 4 new tests covering zoning premiums, loading docks, and coverage ratio. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -37,6 +37,20 @@ class IndustrialAVMRequest(BaseModel):
|
|||||||
power_capacity_kva: float = Field(
|
power_capacity_kva: float = Field(
|
||||||
0.0, ge=0, description="Allocated power capacity in kVA"
|
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(
|
industry_demand_index: float = Field(
|
||||||
0.5, ge=0, le=1, description="Local industry demand index (0-1)"
|
0.5, ge=0, le=1, description="Local industry demand index (0-1)"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ INDUSTRIAL_FEATURE_NAMES = [
|
|||||||
"ceiling_height_m",
|
"ceiling_height_m",
|
||||||
"floor_load_ton_m2",
|
"floor_load_ton_m2",
|
||||||
"power_capacity_kva",
|
"power_capacity_kva",
|
||||||
|
"building_coverage",
|
||||||
|
"loading_docks",
|
||||||
|
"zoning_encoded",
|
||||||
"industry_demand_index",
|
"industry_demand_index",
|
||||||
"fdi_province_musd",
|
"fdi_province_musd",
|
||||||
"labor_cost_province_vnd",
|
"labor_cost_province_vnd",
|
||||||
@@ -56,6 +59,15 @@ PROPERTY_TYPE_MAP = {
|
|||||||
"office_in_park": 5,
|
"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) ────────────────
|
# ── Province-level rent baselines (USD/m²/month) ────────────────
|
||||||
# Based on Vietnamese industrial real estate market data
|
# Based on Vietnamese industrial real estate market data
|
||||||
PROVINCE_BASELINE: dict[str, float] = {
|
PROVINCE_BASELINE: dict[str, float] = {
|
||||||
@@ -113,6 +125,9 @@ def _encode_features(req: IndustrialAVMRequest) -> np.ndarray:
|
|||||||
req.ceiling_height_m,
|
req.ceiling_height_m,
|
||||||
req.floor_load_ton_m2,
|
req.floor_load_ton_m2,
|
||||||
req.power_capacity_kva,
|
req.power_capacity_kva,
|
||||||
|
req.building_coverage,
|
||||||
|
req.loading_docks,
|
||||||
|
ZONING_MAP.get(req.zoning.lower(), 0),
|
||||||
req.industry_demand_index,
|
req.industry_demand_index,
|
||||||
req.fdi_province_musd,
|
req.fdi_province_musd,
|
||||||
req.labor_cost_province_vnd,
|
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)
|
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)
|
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
|
# Economic indicators
|
||||||
demand_adj = 1.0 + (req.industry_demand_index - 0.5) * 0.25
|
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)
|
fdi_adj = 1.0 + min(0.15, req.fdi_province_musd / 5000 * 0.15)
|
||||||
@@ -280,6 +311,9 @@ class IndustrialAVMService:
|
|||||||
* ceiling_adj
|
* ceiling_adj
|
||||||
* floor_load_adj
|
* floor_load_adj
|
||||||
* power_adj
|
* power_adj
|
||||||
|
* coverage_adj
|
||||||
|
* docks_adj
|
||||||
|
* zoning_mult
|
||||||
* demand_adj
|
* demand_adj
|
||||||
* fdi_adj
|
* fdi_adj
|
||||||
* labor_adj
|
* labor_adj
|
||||||
@@ -291,14 +325,17 @@ class IndustrialAVMService:
|
|||||||
|
|
||||||
# Heuristic feature importance
|
# Heuristic feature importance
|
||||||
drivers = [
|
drivers = [
|
||||||
FeatureImportance(feature="province_baseline", importance=0.20),
|
FeatureImportance(feature="province_baseline", importance=0.16),
|
||||||
FeatureImportance(feature="property_type", importance=0.15),
|
FeatureImportance(feature="property_type", importance=0.12),
|
||||||
FeatureImportance(feature="park_occupancy_rate", importance=0.12),
|
FeatureImportance(feature="zoning", importance=0.11),
|
||||||
FeatureImportance(feature="logistics_connectivity_score", importance=0.10),
|
FeatureImportance(feature="park_occupancy_rate", importance=0.10),
|
||||||
FeatureImportance(feature="industry_demand_index", importance=0.10),
|
FeatureImportance(feature="logistics_connectivity_score", importance=0.09),
|
||||||
FeatureImportance(feature="fdi_province_musd", importance=0.08),
|
FeatureImportance(feature="industry_demand_index", importance=0.09),
|
||||||
FeatureImportance(feature="distance_to_port_km", importance=0.07),
|
FeatureImportance(feature="building_coverage", importance=0.07),
|
||||||
FeatureImportance(feature="area_m2", importance=0.06),
|
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(
|
return IndustrialAVMResponse(
|
||||||
|
|||||||
@@ -109,6 +109,62 @@ def test_predict_industrial_total_rent():
|
|||||||
assert abs(resp["total_monthly_rent_usd"] - expected_total) < 1.0
|
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():
|
def test_predict_industrial_validation_error():
|
||||||
"""Missing required fields should return 422."""
|
"""Missing required fields should return 422."""
|
||||||
resp = client.post("/avm/industrial/predict", json={"area_m2": 5000})
|
resp = client.post("/avm/industrial/predict", json={"area_m2": 5000})
|
||||||
|
|||||||
Reference in New Issue
Block a user