thaman / api /models.py
Turki Almurahhem
THAMAN dual-city AVM β€” Stack v22 (NYC) + v12 (Riyadh)
2e8a71f
Raw
History Blame Contribute Delete
11.3 kB
"""
THAMAN API β€” Pydantic request/response schemas
"""
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List
# ── Borough mapping ───────────────────────────────────────────────────
# 1 = Manhattan, 2 = Bronx, 3 = Brooklyn, 4 = Queens, 5 = Staten Island
BOROUGH_NAMES = {1: "Manhattan", 2: "Bronx", 3: "Brooklyn", 4: "Queens", 5: "Staten Island"}
BLDGCLASS_DESCRIPTIONS = {
"A0": "Cape Cod",
"A1": "Two story detached - small or moderate",
"A2": "One story - permanent living quarters",
"A3": "Large suburban residence",
"A4": "City residence - one family",
"A5": "City residence - attached (rowhouse)",
"A6": "Summer residence",
"A7": "Mansion type or town house",
"A8": "Bungalow colony",
"A9": "Single family residence NEC",
"B1": "Two family brick",
"B2": "Two family frame",
"B3": "Two family conversion",
"B9": "Two family NEC",
"C0": "Three families",
"C1": "Over six families without stores",
"C2": "Five to six families",
"C3": "Four families",
"C4": "Old law tenements",
"C5": "Converted dwellings - rooming houses",
"C6": "Cooperative",
"C7": "Walk-up apartments with stores",
"C8": "Walk-up apartment with offices",
"C9": "Walk-up apartment NEC",
"D0": "Elevator cooperative",
"D1": "Elevator apartment (semi-fireproof)",
"D2": "Elevator apartment (artists)",
"D3": "Elevator apartment (fireproof - subsidized)",
"D4": "Elevator apartment building (full building)",
"D5": "Converted elevator building",
"D6": "Elevator apartment with stores",
"D7": "Elevator apartment with offices",
"D8": "Elevator apartment NEC",
"D9": "Elevator apartment NEC",
"R1": "Condo residential unit in elevator building",
"S0": "Primarily one-family with two stores or offices",
"S1": "Primarily one-family with one store or office",
"S2": "Primarily two-family with one store or office",
"S4": "Primarily mixed use (3+ families + commercial)",
"S5": "Converted from one/two family",
}
# ── Request model ─────────────────────────────────────────────────────
class PredictRequest(BaseModel):
# Required: Location
latitude: float = Field(..., ge=40.4, le=40.95, description="Property latitude (NYC: 40.4–40.95)")
longitude: float = Field(..., ge=-74.3, le=-73.7, description="Property longitude (NYC: -74.3 – -73.7)")
# Required: Property basics
gross_square_feet: float = Field(..., gt=0, description="Total building gross square footage")
building_age: int = Field(..., ge=0, le=200, description="Age of building in years")
bldgclass: str = Field(..., description="NYC building class code (e.g. 'A1', 'D4', 'R1')")
borough: int = Field(..., ge=1, le=5, description="Borough: 1=Manhattan, 2=Bronx, 3=Brooklyn, 4=Queens, 5=Staten Island")
numfloors: float = Field(..., gt=0, description="Number of floors")
residential_units: int = Field(..., ge=0, description="Number of residential units")
# Optional property attributes
land_square_feet: Optional[float] = Field(None, ge=0, description="Land area in sq ft")
# Optional: building type flags (auto-inferred from bldgclass if not set)
has_elevator: Optional[int] = Field(None, ge=0, le=1, description="1 if building has elevator")
is_condo: Optional[int] = Field(None, ge=0, le=1, description="1 if condo (R-class)")
is_multifamily: Optional[int] = Field(None, ge=0, le=1, description="1 if multi-family elevator (D-class)")
is_single_fam: Optional[int] = Field(None, ge=0, le=1, description="1 if single family (A-class)")
is_mixed_use: Optional[int] = Field(None, ge=0, le=1, description="1 if mixed-use (S-class)")
# Optional: FAR/Zoning (from PLUTO; defaults to 0 if unknown)
builtfar: Optional[float] = Field(None, ge=0, description="Built Floor Area Ratio")
residfar: Optional[float] = Field(None, ge=0, description="Residential allowable FAR")
commfar: Optional[float] = Field(None, ge=0, description="Commercial allowable FAR")
facilfar: Optional[float] = Field(None, ge=0, description="Community facility allowable FAR")
maxallwfar: Optional[float] = Field(None, ge=0, description="Maximum allowable FAR")
far_utilization: Optional[float] = Field(None, ge=0, description="FAR utilization ratio (builtfar/maxallwfar)")
# Optional: ACRIS prior sale data
prior_sale_price: Optional[float] = Field(None, ge=0, description="Prior recorded sale price (from ACRIS)")
price_appreciation: Optional[float] = Field(None, description="Price appreciation ratio since prior sale")
years_since_prior_sale: Optional[float] = Field(None, ge=0, description="Years since prior sale")
has_prior_sale: Optional[int] = Field(0, ge=0, le=1, description="1 if property has a prior sale record")
is_flip: Optional[int] = Field(0, ge=0, le=1, description="1 if resold within 2 years")
# Optional: Renovation / landmark status
renovated_since_2018: Optional[int] = Field(0, ge=0, le=1, description="1 if major permit since 2018")
years_since_renovation: Optional[float] = Field(0.0, ge=0, description="Years since last major renovation")
# Optional: Time context (defaults to current year/month)
sale_year: Optional[int] = Field(None, ge=2010, le=2030, description="Year of hypothetical sale")
sale_month: Optional[int] = Field(None, ge=1, le=12, description="Month of hypothetical sale")
model_config = ConfigDict(json_schema_extra={
"example": {
"latitude": 40.6892,
"longitude": -73.9442,
"gross_square_feet": 1800,
"building_age": 55,
"bldgclass": "A1",
"borough": 3,
"numfloors": 2,
"residential_units": 1,
"land_square_feet": 2000,
}
})
# ── Response models ───────────────────────────────────────────────────
class FeatureDriver(BaseModel):
feature: str = Field(description="Feature name")
value: float = Field(description="Feature value for this property")
impact: float = Field(description="SHAP value (log-price units)")
direction: str = Field(description="'positive' or 'negative'")
description: str = Field(description="Human-readable feature description")
class AvmQc(BaseModel):
"""2026 AVM Quality Control block β€” confidence score, hit rate, and QC flags."""
confidence_score: int = Field(description="0–100 AVM reliability score (100 – segment MedAPE)")
confidence_grade: str = Field(description="Letter grade: A (β‰₯85) / B (β‰₯75) / C (β‰₯65) / D (<65)")
segment_medape_pct: float = Field(description="Segment-specific MedAPE used for this prediction")
comparables_found: int = Field(description="Training-set sales within 800m (hit rate signal)")
comparables_radius_m: int = Field(default=800, description="Radius used for comparable count")
sparse_market: bool = Field(description="True if fewer than 5 comparables found within 800m")
qc_flags: List[str] = Field(default_factory=list,
description="SPARSE_MARKET | LUXURY_SEGMENT | HIGH_UNCERTAINTY | METRO_CORE")
class SpatialFeatures(BaseModel):
dist_subway_m: float
dist_bus_m: float
dist_express_subway_m: float
nearest_station_is_express: int
dist_school_m: float
dist_elem_school_m: float
dist_park_m: float
dist_hospital_m: float
dist_waterfront_m: float
dist_bike_lane_m: float
poi_count_500m: float
airbnb_count_500m: float
crime_rate_nta: float
noise_density_nta: float
livability_complaint_rate: float
population_2020: float
median_income_nta: float
school_district: float
district_avg_score: float
mortgage_rate_30yr: float
class PredictResponse(BaseModel):
# Core prediction
predicted_price: int
confidence_low: int
confidence_high: int
confidence_note: str = "Β±18.98% MedAPE confidence interval"
# Model metadata
model: str
r2_test: float
medape_pct: float
# Context
borough_name: str
bldgclass_description: str
# Auto-computed spatial features
spatial_features: dict
# SHAP explanations
top_drivers: List[FeatureDriver]
# AVM Quality Control (2026 standard) β€” always present
avm_qc: Optional[AvmQc] = None
# Resolved NTA code (for transparency / debugging)
nta_code: Optional[str] = None
# ── Riyadh predict schemas ────────────────────────────────────────────
RIYADH_TYPE_LABELS = {
"Ψ΄Ω‚Ψ©": "Apartment / Ψ΄Ω‚Ψ©",
"ΩΩŠΩ„Ψ§": "Villa / ΩΩŠΩ„Ψ§",
"Ω‚Ψ·ΨΉΨ© Ψ£Ψ±ΨΆ-Ψ³ΩƒΩ†Ω‰": "Residential Plot / Ω‚Ψ·ΨΉΨ© Ψ£Ψ±ΨΆ Ψ³ΩƒΩ†ΩŠΨ©",
"ΨΉΩ…Ψ§Ψ±Ψ©": "Building / ΨΉΩ…Ψ§Ψ±Ψ©",
}
class RiyadhPredictRequest(BaseModel):
latitude: float = Field(..., ge=23.5, le=26.0, description="Property latitude (Riyadh bbox)")
longitude: float = Field(..., ge=45.5, le=48.0, description="Property longitude (Riyadh bbox)")
property_type: str = Field(..., description="Property type: Ψ΄Ω‚Ψ© | ΩΩŠΩ„Ψ§ | Ω‚Ψ·ΨΉΨ© Ψ£Ψ±ΨΆ-Ψ³ΩƒΩ†Ω‰ | ΨΉΩ…Ψ§Ψ±Ψ©")
area_sqm: float = Field(..., gt=0, description="Property area in square meters")
year: Optional[int] = Field(None, ge=2018, le=2030)
quarter: Optional[int] = Field(None, ge=1, le=4)
model_config = ConfigDict(json_schema_extra={
"example": {
"latitude": 24.7500,
"longitude": 46.6800,
"property_type": "Ψ΄Ω‚Ψ©",
"area_sqm": 150.0,
"year": 2025,
"quarter": 2,
}
})
class RiyadhPredictResponse(BaseModel):
predicted_price_sqm: int = Field(description="Predicted price per sqm (SAR/mΒ²)")
predicted_total_sar: int = Field(description="Estimated total price (SAR) for the given area")
confidence_low_sqm: int
confidence_high_sqm: int
confidence_low_sar: int
confidence_high_sar: int
area_sqm: float
property_type: str
district_ar: Optional[str] = None
model: str
r2_test: float
medape_pct: float
spatial_features: dict
top_drivers: List[FeatureDriver] = Field(default_factory=list, description="Top SHAP feature drivers")
# Asking-price overlay (Bayut listing median for matched district)
asking_price_psqm: Optional[int] = Field(None, description="Bayut median asking price per sqm (SAR/mΒ²)")
asking_price_total: Optional[int] = Field(None, description="Estimated Bayut asking total for given area (SAR)")
asking_spread_pct: Optional[float] = Field(None, description="Asking-price premium over THAMAN transaction estimate (%)")
asking_price_source: Optional[str] = Field(None, description="Source platform for asking-price data")