Download app/forecasting/models.py from JW55/SurfSense: direct link, hf CLI and curl.
- Browser
- Download file 12.7 kB
-
https://huggingface.co/spaces/JW55/SurfSense/resolve/main/app/forecasting/models.py
- Command line
-
hf download hf://spaces/JW55/SurfSense/app/forecasting/models.py
-
curl -L -o models.py https://huggingface.co/spaces/JW55/SurfSense/resolve/main/app/forecasting/models.py
12.7 kB
| """ | |
| SurfSense Forecast Data Models | |
| Unified data schema for surf forecasts that works with multiple data sources. | |
| All forecast data is normalized to these models regardless of the source API. | |
| """ | |
| from datetime import datetime | |
| from enum import Enum | |
| from typing import Optional | |
| from pydantic import BaseModel, Field, field_validator, model_validator | |
| class WindDirection(str, Enum): | |
| """Cardinal wind directions.""" | |
| N = "N" | |
| NNE = "NNE" | |
| NE = "NE" | |
| ENE = "ENE" | |
| E = "E" | |
| ESE = "ESE" | |
| SE = "SE" | |
| SSE = "SSE" | |
| S = "S" | |
| SSW = "SSW" | |
| SW = "SW" | |
| WSW = "WSW" | |
| W = "W" | |
| WNW = "WNW" | |
| NW = "NW" | |
| NNW = "NNW" | |
| class SwellDirection(str, Enum): | |
| """Swell direction categories for surfing.""" | |
| N = "N" | |
| NE = "NE" | |
| E = "E" | |
| SE = "SE" | |
| S = "S" | |
| SW = "SW" | |
| W = "W" | |
| NW = "NW" | |
| class TideState(str, Enum): | |
| """Tide state categories.""" | |
| LOW = "low" | |
| RISING = "rising" | |
| HIGH = "high" | |
| FALLING = "falling" | |
| class Coordinates(BaseModel): | |
| """Geographic coordinates.""" | |
| latitude: float = Field( | |
| ..., | |
| ge=-90.0, | |
| le=90.0, | |
| description="Latitude in decimal degrees", | |
| ) | |
| longitude: float = Field( | |
| ..., | |
| ge=-180.0, | |
| le=180.0, | |
| description="Longitude in decimal degrees", | |
| ) | |
| def __str__(self) -> str: | |
| return f"{self.latitude:.4f}, {self.longitude:.4f}" | |
| class SpotMetadata(BaseModel): | |
| """Basic surf spot information included with forecasts.""" | |
| name: str = Field( | |
| ..., | |
| min_length=1, | |
| description="Name of the surf spot", | |
| ) | |
| coordinates: Coordinates = Field( | |
| ..., | |
| description="Geographic location of the spot", | |
| ) | |
| timezone: str = Field( | |
| default="UTC", | |
| description="Timezone for the spot (e.g., 'America/Los_Angeles')", | |
| ) | |
| class WaveData(BaseModel): | |
| """Wave conditions at a specific time.""" | |
| height_min: float = Field( | |
| ..., | |
| ge=0.0, | |
| le=30.0, | |
| description="Minimum wave height in meters", | |
| ) | |
| height_max: float = Field( | |
| ..., | |
| ge=0.0, | |
| le=30.0, | |
| description="Maximum wave height in meters", | |
| ) | |
| def max_greater_than_min(cls, v: float, info) -> float: | |
| """Ensure max height is >= min height.""" | |
| if "height_min" in info.data and v < info.data["height_min"]: | |
| raise ValueError("height_max must be >= height_min") | |
| return v | |
| def height_avg(self) -> float: | |
| """Average wave height.""" | |
| return (self.height_min + self.height_max) / 2 | |
| def height_range(self) -> tuple[float, float]: | |
| """Return wave height range in meters.""" | |
| return (self.height_min, self.height_max) | |
| class SwellData(BaseModel): | |
| """Primary swell information.""" | |
| height: float = Field( | |
| ..., | |
| ge=0.0, | |
| le=20.0, | |
| description="Swell height in meters", | |
| ) | |
| period: float = Field( | |
| ..., | |
| ge=0.0, | |
| le=30.0, | |
| description="Swell period in seconds", | |
| ) | |
| direction_degrees: float = Field( | |
| ..., | |
| ge=0.0, | |
| lt=360.0, | |
| description="Swell direction in degrees (0-359, where 0 is North)", | |
| ) | |
| direction: Optional[SwellDirection] = Field( | |
| default=None, | |
| description="Swell direction as cardinal direction", | |
| ) | |
| def compute_direction(self) -> "SwellData": | |
| """Compute cardinal direction from degrees if not provided.""" | |
| if self.direction is None: | |
| self.direction = self._degrees_to_cardinal(self.direction_degrees) | |
| return self | |
| def _degrees_to_cardinal(degrees: float) -> SwellDirection: | |
| """Convert degrees to cardinal direction.""" | |
| directions = [ | |
| SwellDirection.N, SwellDirection.NE, SwellDirection.E, SwellDirection.SE, | |
| SwellDirection.S, SwellDirection.SW, SwellDirection.W, SwellDirection.NW, | |
| ] | |
| index = round(degrees / 45) % 8 | |
| return directions[index] | |
| class WindData(BaseModel): | |
| """Wind conditions.""" | |
| speed: float = Field( | |
| ..., | |
| ge=0.0, | |
| le=200.0, | |
| description="Wind speed in km/h", | |
| ) | |
| gust: Optional[float] = Field( | |
| default=None, | |
| ge=0.0, | |
| le=300.0, | |
| description="Wind gust speed in km/h", | |
| ) | |
| direction_degrees: float = Field( | |
| ..., | |
| ge=0.0, | |
| lt=360.0, | |
| description="Wind direction in degrees (0-359, where 0 is North)", | |
| ) | |
| direction: Optional[WindDirection] = Field( | |
| default=None, | |
| description="Wind direction as cardinal direction", | |
| ) | |
| def compute_direction(self) -> "WindData": | |
| """Compute cardinal direction from degrees if not provided.""" | |
| if self.direction is None: | |
| self.direction = self._degrees_to_cardinal(self.direction_degrees) | |
| return self | |
| def _degrees_to_cardinal(degrees: float) -> WindDirection: | |
| """Convert degrees to 16-point cardinal direction.""" | |
| directions = [ | |
| WindDirection.N, WindDirection.NNE, WindDirection.NE, WindDirection.ENE, | |
| WindDirection.E, WindDirection.ESE, WindDirection.SE, WindDirection.SSE, | |
| WindDirection.S, WindDirection.SSW, WindDirection.SW, WindDirection.WSW, | |
| WindDirection.W, WindDirection.WNW, WindDirection.NW, WindDirection.NNW, | |
| ] | |
| index = round(degrees / 22.5) % 16 | |
| return directions[index] | |
| def speed_in_knots(self) -> float: | |
| """Convert wind speed to knots.""" | |
| return self.speed * 0.54 | |
| def speed_in_mph(self) -> float: | |
| """Convert wind speed to mph.""" | |
| return self.speed * 0.621 | |
| class TideData(BaseModel): | |
| """Tide information.""" | |
| height: float = Field( | |
| ..., | |
| ge=-5.0, | |
| le=15.0, | |
| description="Tide height in meters relative to mean sea level", | |
| ) | |
| state: Optional[TideState] = Field( | |
| default=None, | |
| description="Current tide state (low, rising, high, falling)", | |
| ) | |
| class WeatherData(BaseModel): | |
| """General weather conditions.""" | |
| temperature: Optional[float] = Field( | |
| default=None, | |
| ge=-50.0, | |
| le=60.0, | |
| description="Air temperature in Celsius", | |
| ) | |
| water_temperature: Optional[float] = Field( | |
| default=None, | |
| ge=-5.0, | |
| le=40.0, | |
| description="Water temperature in Celsius", | |
| ) | |
| description: Optional[str] = Field( | |
| default=None, | |
| description="Weather description (e.g., 'Partly cloudy')", | |
| ) | |
| cloud_cover: Optional[float] = Field( | |
| default=None, | |
| ge=0.0, | |
| le=100.0, | |
| description="Cloud cover percentage", | |
| ) | |
| precipitation: Optional[float] = Field( | |
| default=None, | |
| ge=0.0, | |
| description="Precipitation in mm/h", | |
| ) | |
| visibility: Optional[float] = Field( | |
| default=None, | |
| ge=0.0, | |
| description="Visibility in kilometers", | |
| ) | |
| class ForecastPoint(BaseModel): | |
| """ | |
| A single forecast data point for a specific time. | |
| This is the core model representing surf conditions at one moment in time. | |
| All forecast sources are normalized to this format. | |
| """ | |
| timestamp: datetime = Field( | |
| ..., | |
| description="UTC timestamp for this forecast point", | |
| ) | |
| waves: WaveData = Field( | |
| ..., | |
| description="Wave conditions", | |
| ) | |
| swell: SwellData = Field( | |
| ..., | |
| description="Primary swell data", | |
| ) | |
| wind: WindData = Field( | |
| ..., | |
| description="Wind conditions", | |
| ) | |
| tide: Optional[TideData] = Field( | |
| default=None, | |
| description="Tide information (may not be available from all sources)", | |
| ) | |
| weather: Optional[WeatherData] = Field( | |
| default=None, | |
| description="General weather conditions", | |
| ) | |
| def is_offshore_wind(self) -> bool: | |
| """ | |
| Check if wind is offshore (generally good for surfing). | |
| Note: This is a simplified check. Real offshore detection | |
| depends on the specific beach orientation. | |
| """ | |
| # Simplified: winds from land (E, NE, SE for west-facing beaches) | |
| offshore_directions = { | |
| WindDirection.E, WindDirection.ENE, WindDirection.ESE, | |
| WindDirection.NE, WindDirection.SE, | |
| } | |
| return self.wind.direction in offshore_directions | |
| def is_light_wind(self) -> bool: | |
| """Check if wind is light (< 15 km/h).""" | |
| return self.wind.speed < 15 | |
| def summary(self) -> str: | |
| """Generate a human-readable summary of conditions.""" | |
| wave_m = self.waves.height_range() | |
| return ( | |
| f"{self.timestamp.strftime('%Y-%m-%d %H:%M')} UTC: " | |
| f"Waves {wave_m[0]:.1f}-{wave_m[1]:.1f}m, " | |
| f"Swell {self.swell.period:.0f}s from {self.swell.direction.value if self.swell.direction else 'N/A'}, " | |
| f"Wind {self.wind.speed:.0f}km/h {self.wind.direction.value if self.wind.direction else 'N/A'}" | |
| ) | |
| class DataSource(str, Enum): | |
| """Forecast data source identifier.""" | |
| STORMGLASS = "stormglass" | |
| OPEN_METEO = "open_meteo" | |
| SURFLINE = "surfline" | |
| NOAA = "noaa" | |
| LOCAL_MODEL = "local_model" | |
| UNKNOWN = "unknown" | |
| class ForecastResponse(BaseModel): | |
| """ | |
| Complete forecast response containing multiple forecast points. | |
| This is the main response model returned by the forecast service. | |
| """ | |
| spot: SpotMetadata = Field( | |
| ..., | |
| description="Information about the surf spot", | |
| ) | |
| forecasts: list[ForecastPoint] = Field( | |
| ..., | |
| min_length=1, | |
| description="List of forecast points ordered by timestamp", | |
| ) | |
| source: DataSource = Field( | |
| default=DataSource.UNKNOWN, | |
| description="Data source for this forecast", | |
| ) | |
| fetched_at: datetime = Field( | |
| default_factory=datetime.utcnow, | |
| description="When this forecast was retrieved", | |
| ) | |
| expires_at: Optional[datetime] = Field( | |
| default=None, | |
| description="When this forecast data expires (for caching)", | |
| ) | |
| def start_time(self) -> datetime: | |
| """First forecast timestamp.""" | |
| return self.forecasts[0].timestamp | |
| def end_time(self) -> datetime: | |
| """Last forecast timestamp.""" | |
| return self.forecasts[-1].timestamp | |
| def duration_hours(self) -> float: | |
| """Total forecast duration in hours.""" | |
| delta = self.end_time - self.start_time | |
| return delta.total_seconds() / 3600 | |
| def get_forecast_at(self, target_time: datetime) -> Optional[ForecastPoint]: | |
| """ | |
| Get the forecast point closest to the target time. | |
| Args: | |
| target_time: The time to find forecast for. | |
| Returns: | |
| Closest ForecastPoint or None if no forecasts available. | |
| """ | |
| if not self.forecasts: | |
| return None | |
| closest = min( | |
| self.forecasts, | |
| key=lambda f: abs((f.timestamp - target_time).total_seconds()) | |
| ) | |
| return closest | |
| def get_forecasts_for_date(self, date: datetime) -> list[ForecastPoint]: | |
| """ | |
| Get all forecast points for a specific date. | |
| Args: | |
| date: The date to filter by. | |
| Returns: | |
| List of ForecastPoints for that date. | |
| """ | |
| return [ | |
| f for f in self.forecasts | |
| if f.timestamp.date() == date.date() | |
| ] | |
| def best_conditions(self, max_wind_speed: float = 20.0) -> list[ForecastPoint]: | |
| """ | |
| Find forecast points with the best surfing conditions. | |
| Simple heuristic: larger waves + longer period + lighter wind = better. | |
| Args: | |
| max_wind_speed: Maximum acceptable wind speed in km/h. | |
| Returns: | |
| List of ForecastPoints sorted by quality (best first). | |
| """ | |
| suitable = [f for f in self.forecasts if f.wind.speed <= max_wind_speed] | |
| def score(f: ForecastPoint) -> float: | |
| # Higher score = better conditions | |
| wave_score = f.waves.height_avg * 10 | |
| period_score = f.swell.period * 2 | |
| wind_penalty = f.wind.speed * 0.5 | |
| return wave_score + period_score - wind_penalty | |
| return sorted(suitable, key=score, reverse=True) | |