124 lines
4.9 KiB
Python
124 lines
4.9 KiB
Python
"""
|
|
POI Context Model
|
|
|
|
Stores Point of Interest detection results and ML features for bakery locations.
|
|
Used for location-based demand forecasting with contextual features.
|
|
"""
|
|
|
|
from sqlalchemy import Column, String, DateTime, Float, Index, Integer
|
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
|
from datetime import datetime, timezone, timedelta
|
|
import uuid
|
|
|
|
from app.core.database import Base
|
|
|
|
|
|
class TenantPOIContext(Base):
|
|
"""
|
|
POI (Point of Interest) context for bakery location.
|
|
|
|
Stores detected POIs around bakery and calculated ML features
|
|
for demand forecasting with location-specific context.
|
|
"""
|
|
|
|
__tablename__ = "tenant_poi_contexts"
|
|
|
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
tenant_id = Column(UUID(as_uuid=True), nullable=False, unique=True, index=True)
|
|
|
|
# Location (denormalized for quick reference and spatial queries)
|
|
latitude = Column(Float, nullable=False)
|
|
longitude = Column(Float, nullable=False)
|
|
|
|
# POI Detection Results (full raw data)
|
|
# Structure: {
|
|
# "schools": {
|
|
# "pois": [{"osm_id": "...", "name": "...", "lat": ..., "lon": ...}],
|
|
# "features": {"proximity_score": 3.45, "count_0_100m": 2, ...},
|
|
# "count": 5
|
|
# },
|
|
# "offices": {...},
|
|
# ...
|
|
# }
|
|
poi_detection_results = Column(JSONB, nullable=False, default=dict)
|
|
|
|
# ML Features (flat structure for easy model ingestion)
|
|
# Structure: {
|
|
# "poi_schools_proximity_score": 3.45,
|
|
# "poi_schools_weighted_proximity_score": 5.18,
|
|
# "poi_schools_count_0_100m": 2,
|
|
# "poi_offices_proximity_score": 1.23,
|
|
# ...
|
|
# }
|
|
ml_features = Column(JSONB, nullable=False, default=dict)
|
|
|
|
# Summary Statistics
|
|
total_pois_detected = Column(Integer, default=0)
|
|
high_impact_categories = Column(JSONB, default=list) # Categories with significant POI presence
|
|
relevant_categories = Column(JSONB, default=list) # Categories that passed relevance thresholds
|
|
|
|
# Detection Metadata
|
|
detection_timestamp = Column(DateTime(timezone=True), nullable=False)
|
|
detection_source = Column(String(50), default="overpass_api")
|
|
detection_status = Column(String(20), default="completed") # completed, failed, partial
|
|
detection_error = Column(String(500), nullable=True) # Error message if detection failed
|
|
|
|
# Data Freshness Strategy
|
|
# POIs don't change frequently, refresh every 6 months
|
|
next_refresh_date = Column(DateTime(timezone=True), nullable=True)
|
|
refresh_interval_days = Column(Integer, default=180) # 6 months default
|
|
last_refreshed_at = Column(DateTime(timezone=True), nullable=True)
|
|
|
|
# Timestamps
|
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
|
updated_at = Column(
|
|
DateTime(timezone=True),
|
|
default=lambda: datetime.now(timezone.utc),
|
|
onupdate=lambda: datetime.now(timezone.utc)
|
|
)
|
|
|
|
__table_args__ = (
|
|
Index('idx_tenant_poi_location', 'latitude', 'longitude'),
|
|
Index('idx_tenant_poi_refresh', 'next_refresh_date'),
|
|
Index('idx_tenant_poi_status', 'detection_status'),
|
|
)
|
|
|
|
def to_dict(self):
|
|
"""Convert to dictionary for API responses"""
|
|
return {
|
|
"id": str(self.id),
|
|
"tenant_id": str(self.tenant_id),
|
|
"location": {
|
|
"latitude": self.latitude,
|
|
"longitude": self.longitude
|
|
},
|
|
"poi_detection_results": self.poi_detection_results,
|
|
"ml_features": self.ml_features,
|
|
"total_pois_detected": self.total_pois_detected,
|
|
"high_impact_categories": self.high_impact_categories,
|
|
"relevant_categories": self.relevant_categories,
|
|
"detection_timestamp": self.detection_timestamp.isoformat() if self.detection_timestamp else None,
|
|
"detection_source": self.detection_source,
|
|
"detection_status": self.detection_status,
|
|
"detection_error": self.detection_error,
|
|
"next_refresh_date": self.next_refresh_date.isoformat() if self.next_refresh_date else None,
|
|
"last_refreshed_at": self.last_refreshed_at.isoformat() if self.last_refreshed_at else None,
|
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None
|
|
}
|
|
|
|
def is_stale(self) -> bool:
|
|
"""Check if POI data needs refresh"""
|
|
if not self.next_refresh_date:
|
|
return True
|
|
return datetime.now(timezone.utc) > self.next_refresh_date
|
|
|
|
def calculate_next_refresh(self) -> datetime:
|
|
"""Calculate next refresh date based on interval"""
|
|
return datetime.now(timezone.utc) + timedelta(days=self.refresh_interval_days)
|
|
|
|
def mark_refreshed(self):
|
|
"""Mark as refreshed and calculate next refresh date"""
|
|
self.last_refreshed_at = datetime.now(timezone.utc)
|
|
self.next_refresh_date = self.calculate_next_refresh()
|