diff --git a/.env.example b/.env.example index 70a54cf..49c7f3a 100644 --- a/.env.example +++ b/.env.example @@ -63,14 +63,18 @@ FRONTEND_PORT=3000 # ============================================================================= # WEATHER DATA CONFIGURATION # ============================================================================= +# Without credentials, the system falls back to synthetic data automatically. -# Use mock data (true) or real Copernicus data (false) -COPERNICUS_MOCK_MODE=true +# CDS API — ERA5 wind data +# Register at: https://cds.climate.copernicus.eu/ +# Your Personal Access Token is on your CDS profile page. +CDSAPI_URL=https://cds.climate.copernicus.eu/api +CDSAPI_KEY= -# Copernicus Marine Service credentials (required if COPERNICUS_MOCK_MODE=false) +# Copernicus Marine Service — wave, current, and SST data # Register at: https://marine.copernicus.eu/ -COPERNICUS_USERNAME= -COPERNICUS_PASSWORD= +COPERNICUSMARINE_SERVICE_USERNAME= +COPERNICUSMARINE_SERVICE_PASSWORD= # ============================================================================= # MONITORING & OBSERVABILITY diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0837732..8863e7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,20 @@ jobs: working-directory: frontend run: npx tsc --noEmit + - name: Run unit tests + working-directory: frontend + run: npm test -- --coverage --watchAll=false + env: + CI: true + + - name: Upload frontend coverage + uses: codecov/codecov-action@v4 + with: + files: frontend/coverage/lcov.info + flags: frontend + name: frontend-coverage + continue-on-error: true + - name: Build frontend working-directory: frontend run: npm run build diff --git a/Dockerfile b/Dockerfile index bf57c2c..aba2127 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,7 @@ LABEL org.opencontainers.image.title="WINDMAR API" \ org.opencontainers.image.description="Maritime Route Optimization API" \ org.opencontainers.image.vendor="SL Mar" \ org.opencontainers.image.version="2.1.0" \ - org.opencontainers.image.licenses="Commercial" + org.opencontainers.image.licenses="Apache-2.0" # Security: Run as non-root user RUN groupadd --gid 1000 windmar \ diff --git a/api/cache.py b/api/cache.py new file mode 100644 index 0000000..6918251 --- /dev/null +++ b/api/cache.py @@ -0,0 +1,365 @@ +""" +Thread-safe LRU cache with bounded size for WINDMAR API. + +Provides a production-grade caching solution that: +- Bounds memory usage with configurable max entries +- Uses LRU eviction when cache is full +- Supports TTL (time-to-live) for entries +- Is thread-safe for concurrent access +- Provides metrics for monitoring +""" +import threading +import logging +from typing import TypeVar, Optional, Dict, Any, Callable +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from collections import OrderedDict +import functools + +logger = logging.getLogger(__name__) + +K = TypeVar('K') +V = TypeVar('V') + + +@dataclass +class CacheEntry: + """Single cache entry with metadata.""" + value: Any + created_at: datetime + expires_at: Optional[datetime] + access_count: int = 0 + last_accessed: datetime = field(default_factory=datetime.utcnow) + + +class BoundedLRUCache: + """ + Thread-safe LRU cache with bounded size and TTL support. + + Features: + - Maximum entry limit with LRU eviction + - Optional TTL for automatic expiration + - Thread-safe for concurrent read/write + - Metrics tracking (hits, misses, evictions) + + Usage: + cache = BoundedLRUCache(max_size=1000, default_ttl_seconds=3600) + cache.set("key", value) + result = cache.get("key") + """ + + def __init__( + self, + max_size: int = 1000, + default_ttl_seconds: Optional[int] = 3600, + name: str = "default" + ): + """ + Initialize cache. + + Args: + max_size: Maximum number of entries before LRU eviction + default_ttl_seconds: Default TTL for entries (None = no expiration) + name: Cache name for logging/metrics + """ + self.max_size = max_size + self.default_ttl_seconds = default_ttl_seconds + self.name = name + + self._cache: OrderedDict[Any, CacheEntry] = OrderedDict() + self._lock = threading.RLock() + + # Metrics + self._hits = 0 + self._misses = 0 + self._evictions = 0 + self._expirations = 0 + + def get(self, key: K) -> Optional[V]: + """ + Get value from cache. + + Args: + key: Cache key + + Returns: + Cached value or None if not found/expired + """ + with self._lock: + if key not in self._cache: + self._misses += 1 + return None + + entry = self._cache[key] + + # Check expiration + if entry.expires_at and datetime.utcnow() > entry.expires_at: + self._remove(key) + self._expirations += 1 + self._misses += 1 + return None + + # Update access metadata and move to end (most recently used) + entry.access_count += 1 + entry.last_accessed = datetime.utcnow() + self._cache.move_to_end(key) + + self._hits += 1 + return entry.value + + def set( + self, + key: K, + value: V, + ttl_seconds: Optional[int] = None + ) -> None: + """ + Set value in cache. + + Args: + key: Cache key + value: Value to cache + ttl_seconds: TTL for this entry (None = use default) + """ + with self._lock: + # Determine expiration + ttl = ttl_seconds if ttl_seconds is not None else self.default_ttl_seconds + expires_at = None + if ttl: + expires_at = datetime.utcnow() + timedelta(seconds=ttl) + + # Create entry + entry = CacheEntry( + value=value, + created_at=datetime.utcnow(), + expires_at=expires_at, + ) + + # Update or insert + if key in self._cache: + self._cache[key] = entry + self._cache.move_to_end(key) + else: + # Check if we need to evict + while len(self._cache) >= self.max_size: + self._evict_oldest() + + self._cache[key] = entry + + def delete(self, key: K) -> bool: + """ + Delete entry from cache. + + Args: + key: Cache key + + Returns: + True if key was present and deleted + """ + with self._lock: + if key in self._cache: + self._remove(key) + return True + return False + + def clear(self) -> int: + """ + Clear all entries from cache. + + Returns: + Number of entries cleared + """ + with self._lock: + count = len(self._cache) + self._cache.clear() + logger.info(f"Cache '{self.name}' cleared: {count} entries removed") + return count + + def _remove(self, key: K) -> None: + """Remove entry without lock (internal use).""" + del self._cache[key] + + def _evict_oldest(self) -> None: + """Evict oldest (least recently used) entry.""" + if self._cache: + oldest_key = next(iter(self._cache)) + self._remove(oldest_key) + self._evictions += 1 + logger.debug(f"Cache '{self.name}' evicted: {oldest_key}") + + def cleanup_expired(self) -> int: + """ + Remove all expired entries. + + Returns: + Number of entries removed + """ + with self._lock: + now = datetime.utcnow() + expired_keys = [ + key for key, entry in self._cache.items() + if entry.expires_at and now > entry.expires_at + ] + + for key in expired_keys: + self._remove(key) + self._expirations += 1 + + if expired_keys: + logger.debug(f"Cache '{self.name}' cleanup: {len(expired_keys)} expired entries removed") + + return len(expired_keys) + + def get_or_set( + self, + key: K, + factory: Callable[[], V], + ttl_seconds: Optional[int] = None + ) -> V: + """ + Get value from cache, or compute and cache it if missing. + + Args: + key: Cache key + factory: Function to compute value if not cached + ttl_seconds: TTL for this entry + + Returns: + Cached or computed value + """ + # Try to get from cache first + value = self.get(key) + if value is not None: + return value + + # Compute value + value = factory() + + # Cache it + self.set(key, value, ttl_seconds) + + return value + + def get_stats(self) -> Dict[str, Any]: + """ + Get cache statistics. + + Returns: + Dict with cache metrics + """ + with self._lock: + total_requests = self._hits + self._misses + hit_rate = self._hits / total_requests if total_requests > 0 else 0.0 + + return { + 'name': self.name, + 'size': len(self._cache), + 'max_size': self.max_size, + 'hits': self._hits, + 'misses': self._misses, + 'hit_rate': round(hit_rate, 4), + 'evictions': self._evictions, + 'expirations': self._expirations, + 'default_ttl_seconds': self.default_ttl_seconds, + } + + def __len__(self) -> int: + """Get number of entries in cache.""" + with self._lock: + return len(self._cache) + + def __contains__(self, key: K) -> bool: + """Check if key is in cache (without updating access time).""" + with self._lock: + if key not in self._cache: + return False + entry = self._cache[key] + if entry.expires_at and datetime.utcnow() > entry.expires_at: + return False + return True + + +def cached( + cache: BoundedLRUCache, + key_func: Optional[Callable[..., str]] = None, + ttl_seconds: Optional[int] = None, +): + """ + Decorator to cache function results. + + Args: + cache: BoundedLRUCache instance to use + key_func: Function to generate cache key from args (default: str of args) + ttl_seconds: TTL for cached results + + Usage: + weather_cache = BoundedLRUCache(max_size=100, name="weather") + + @cached(weather_cache, key_func=lambda lat, lon: f"{lat:.1f},{lon:.1f}") + def get_weather(lat: float, lon: float): + ... + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args, **kwargs): + # Generate cache key + if key_func: + key = key_func(*args, **kwargs) + else: + key = f"{func.__name__}:{args}:{sorted(kwargs.items())}" + + # Check cache + result = cache.get(key) + if result is not None: + return result + + # Compute and cache + result = func(*args, **kwargs) + cache.set(key, result, ttl_seconds) + + return result + + # Attach cache reference for testing/inspection + wrapper._cache = cache + + return wrapper + + return decorator + + +# Pre-configured caches for common use cases +weather_cache = BoundedLRUCache( + max_size=500, + default_ttl_seconds=3600, # 1 hour + name="weather" +) + +route_cache = BoundedLRUCache( + max_size=100, + default_ttl_seconds=1800, # 30 minutes + name="routes" +) + +calculation_cache = BoundedLRUCache( + max_size=200, + default_ttl_seconds=900, # 15 minutes + name="calculations" +) + + +def get_all_cache_stats() -> Dict[str, Dict[str, Any]]: + """Get stats for all registered caches.""" + return { + 'weather': weather_cache.get_stats(), + 'routes': route_cache.get_stats(), + 'calculations': calculation_cache.get_stats(), + } + + +def cleanup_all_caches() -> Dict[str, int]: + """Cleanup expired entries from all caches.""" + return { + 'weather': weather_cache.cleanup_expired(), + 'routes': route_cache.cleanup_expired(), + 'calculations': calculation_cache.cleanup_expired(), + } diff --git a/api/config.py b/api/config.py index 3a039c1..e43fc6d 100644 --- a/api/config.py +++ b/api/config.py @@ -76,6 +76,26 @@ def is_development(self) -> bool: """Check if running in development.""" return self.environment.lower() == "development" + # ======================================================================== + # Copernicus Weather Data + # ======================================================================== + # CDS API (ERA5 wind data) — register at https://cds.climate.copernicus.eu + cdsapi_url: str = "https://cds.climate.copernicus.eu/api" + cdsapi_key: Optional[str] = None + + # CMEMS (wave/current data) — register at https://marine.copernicus.eu + copernicusmarine_service_username: Optional[str] = None + copernicusmarine_service_password: Optional[str] = None + + @property + def has_cds_credentials(self) -> bool: + return self.cdsapi_key is not None + + @property + def has_cmems_credentials(self) -> bool: + return (self.copernicusmarine_service_username is not None + and self.copernicusmarine_service_password is not None) + # ======================================================================== # Performance Configuration # ======================================================================== diff --git a/api/health.py b/api/health.py new file mode 100644 index 0000000..53415ec --- /dev/null +++ b/api/health.py @@ -0,0 +1,308 @@ +""" +Comprehensive health check module for WINDMAR API. + +Provides detailed health checks for all dependencies and system components. +Designed for Kubernetes liveness/readiness probes and load balancer health checks. +""" +import logging +import asyncio +from typing import Dict, Any, Optional +from datetime import datetime +from enum import Enum +from dataclasses import dataclass +import redis + +from api.config import settings +from api.cache import get_all_cache_stats +from api.resilience import get_all_circuit_breaker_status + +logger = logging.getLogger(__name__) + + +class HealthStatus(Enum): + """Health check status.""" + HEALTHY = "healthy" + DEGRADED = "degraded" + UNHEALTHY = "unhealthy" + + +@dataclass +class ComponentHealth: + """Health status of a single component.""" + name: str + status: HealthStatus + latency_ms: Optional[float] = None + message: Optional[str] = None + details: Optional[Dict[str, Any]] = None + + +def check_database_health() -> ComponentHealth: + """ + Check PostgreSQL database connectivity. + + Returns: + ComponentHealth with database status + """ + start = datetime.utcnow() + + try: + from api.database import SessionLocal + + db = SessionLocal() + try: + # Execute simple query + result = db.execute("SELECT 1").scalar() + latency_ms = (datetime.utcnow() - start).total_seconds() * 1000 + + if result == 1: + return ComponentHealth( + name="database", + status=HealthStatus.HEALTHY, + latency_ms=round(latency_ms, 2), + message="PostgreSQL connected", + ) + else: + return ComponentHealth( + name="database", + status=HealthStatus.UNHEALTHY, + message="Unexpected query result", + ) + finally: + db.close() + + except Exception as e: + latency_ms = (datetime.utcnow() - start).total_seconds() * 1000 + logger.error(f"Database health check failed: {e}") + return ComponentHealth( + name="database", + status=HealthStatus.UNHEALTHY, + latency_ms=round(latency_ms, 2), + message=f"Connection failed: {type(e).__name__}", + ) + + +def check_redis_health() -> ComponentHealth: + """ + Check Redis connectivity. + + Returns: + ComponentHealth with Redis status + """ + start = datetime.utcnow() + + if not settings.redis_enabled: + return ComponentHealth( + name="redis", + status=HealthStatus.HEALTHY, + message="Redis disabled (not required)", + ) + + try: + client = redis.from_url( + settings.redis_url, + socket_connect_timeout=5, + socket_timeout=5, + ) + pong = client.ping() + latency_ms = (datetime.utcnow() - start).total_seconds() * 1000 + + if pong: + # Get some stats + info = client.info(section="memory") + return ComponentHealth( + name="redis", + status=HealthStatus.HEALTHY, + latency_ms=round(latency_ms, 2), + message="Redis connected", + details={ + "used_memory_human": info.get("used_memory_human"), + "connected_clients": info.get("connected_clients", "N/A"), + }, + ) + else: + return ComponentHealth( + name="redis", + status=HealthStatus.UNHEALTHY, + message="Ping failed", + ) + + except Exception as e: + latency_ms = (datetime.utcnow() - start).total_seconds() * 1000 + logger.error(f"Redis health check failed: {e}") + return ComponentHealth( + name="redis", + status=HealthStatus.DEGRADED if not settings.redis_enabled else HealthStatus.UNHEALTHY, + latency_ms=round(latency_ms, 2), + message=f"Connection failed: {type(e).__name__}", + ) + + +def check_weather_provider_health() -> ComponentHealth: + """ + Check weather data provider status. + + Returns: + ComponentHealth with provider status + """ + try: + from api.state import get_app_state + + app_state = get_app_state() + providers = app_state.weather_providers + + if providers is None: + return ComponentHealth( + name="weather_provider", + status=HealthStatus.DEGRADED, + message="Providers not initialized", + ) + + copernicus = providers.get('copernicus') + has_cds = copernicus._has_cdsapi if copernicus else False + has_cmems = copernicus._has_copernicusmarine if copernicus else False + + if has_cds and has_cmems: + status = HealthStatus.HEALTHY + message = "Full Copernicus access available" + elif has_cds or has_cmems: + status = HealthStatus.DEGRADED + message = "Partial Copernicus access" + else: + status = HealthStatus.DEGRADED + message = "Using synthetic data fallback" + + return ComponentHealth( + name="weather_provider", + status=status, + message=message, + details={ + "cds_available": has_cds, + "cmems_available": has_cmems, + "fallback_available": True, + }, + ) + + except Exception as e: + logger.error(f"Weather provider health check failed: {e}") + return ComponentHealth( + name="weather_provider", + status=HealthStatus.DEGRADED, + message=f"Check failed: {type(e).__name__}", + ) + + +async def perform_full_health_check() -> Dict[str, Any]: + """ + Perform comprehensive health check of all components. + + Returns: + Dict with overall status and component details + """ + start = datetime.utcnow() + + # Run health checks + db_health = check_database_health() + redis_health = check_redis_health() + weather_health = check_weather_provider_health() + + components = [db_health, redis_health, weather_health] + + # Determine overall status + unhealthy_count = sum(1 for c in components if c.status == HealthStatus.UNHEALTHY) + degraded_count = sum(1 for c in components if c.status == HealthStatus.DEGRADED) + + if unhealthy_count > 0: + overall_status = HealthStatus.UNHEALTHY + elif degraded_count > 0: + overall_status = HealthStatus.DEGRADED + else: + overall_status = HealthStatus.HEALTHY + + total_time_ms = (datetime.utcnow() - start).total_seconds() * 1000 + + return { + "status": overall_status.value, + "timestamp": datetime.utcnow().isoformat() + "Z", + "version": "2.1.0", + "check_duration_ms": round(total_time_ms, 2), + "components": { + c.name: { + "status": c.status.value, + "latency_ms": c.latency_ms, + "message": c.message, + **({"details": c.details} if c.details else {}), + } + for c in components + }, + } + + +async def perform_liveness_check() -> Dict[str, Any]: + """ + Simple liveness check for Kubernetes probes. + + This should be fast and only check if the service is alive, + not if all dependencies are healthy. + + Returns: + Dict with basic status + """ + return { + "status": "alive", + "timestamp": datetime.utcnow().isoformat() + "Z", + } + + +async def perform_readiness_check() -> Dict[str, Any]: + """ + Readiness check for Kubernetes probes. + + Checks if the service is ready to accept traffic. + Includes database connectivity check. + + Returns: + Dict with readiness status + """ + db_health = check_database_health() + + # Service is ready if database is connected + is_ready = db_health.status == HealthStatus.HEALTHY + + return { + "status": "ready" if is_ready else "not_ready", + "timestamp": datetime.utcnow().isoformat() + "Z", + "database": db_health.status.value, + } + + +async def get_detailed_status() -> Dict[str, Any]: + """ + Get detailed system status including metrics and cache stats. + + Returns: + Dict with comprehensive system information + """ + health = await perform_full_health_check() + + # Add cache stats + cache_stats = get_all_cache_stats() + + # Add circuit breaker status + circuit_breakers = get_all_circuit_breaker_status() + + # Get uptime + from api.state import get_app_state + app_state = get_app_state() + + return { + **health, + "uptime_seconds": round(app_state.uptime_seconds, 2), + "environment": settings.environment, + "caches": cache_stats, + "circuit_breakers": circuit_breakers, + "config": { + "auth_enabled": settings.auth_enabled, + "rate_limit_enabled": settings.rate_limit_enabled, + "metrics_enabled": settings.metrics_enabled, + }, + } diff --git a/api/main.py b/api/main.py index c712175..34e2cc5 100644 --- a/api/main.py +++ b/api/main.py @@ -8,26 +8,31 @@ - Vessel configuration Version: 2.1.0 -License: Commercial - See LICENSE file +License: Apache 2.0 - See LICENSE file """ import io import logging import math +import os from datetime import datetime, timedelta from pathlib import Path from typing import Dict, List, Optional, Tuple import numpy as np -from fastapi import FastAPI, HTTPException, UploadFile, File, Query, Response +from fastapi import FastAPI, HTTPException, UploadFile, File, Query, Response, Depends, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import PlainTextResponse +from fastapi.responses import PlainTextResponse, JSONResponse from pydantic import BaseModel, Field from fastapi.exceptions import RequestValidationError -from starlette.requests import Request -from starlette.responses import JSONResponse +from slowapi.errors import RateLimitExceeded import uvicorn +# File upload size limits (security) +MAX_UPLOAD_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB general limit +MAX_RTZ_SIZE_BYTES = 5 * 1024 * 1024 # 5 MB for RTZ files +MAX_CSV_SIZE_BYTES = 50 * 1024 * 1024 # 50 MB for CSV files + # Import WINDMAR modules import sys sys.path.insert(0, str(Path(__file__).parent.parent)) @@ -75,6 +80,11 @@ def _build_ocean_mask(lat_min, lat_max, lon_min, lon_max, step=0.05): structured_logger, get_request_id, ) +from api.auth import get_api_key, get_optional_api_key +from api.rate_limit import limiter, get_rate_limit_string +from api.state import get_app_state, get_vessel_state +from api.cache import weather_cache, get_all_cache_stats +from api.resilience import get_all_circuit_breaker_status # Configure structured logging for production logging.basicConfig( @@ -129,8 +139,8 @@ def create_app() -> FastAPI: redoc_url="/api/redoc", openapi_url="/api/openapi.json", license_info={ - "name": "Commercial License", - "url": "https://windmar.io/license", + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0", }, contact={ "name": "WINDMAR Support", @@ -155,12 +165,31 @@ def create_app() -> FastAPI: allow_headers=["*"], ) + # Add rate limiter to app state + application.state.limiter = limiter + + # Add rate limit exception handler + @application.exception_handler(RateLimitExceeded) + async def rate_limit_handler(request: Request, exc: RateLimitExceeded): + return JSONResponse( + status_code=429, + content={ + "error": "Rate limit exceeded", + "detail": str(exc.detail), + "retry_after": getattr(exc, 'retry_after', 60), + }, + headers={"Retry-After": str(getattr(exc, 'retry_after', 60))}, + ) + return application # Create the application app = create_app() +# Initialize application state (thread-safe singleton) +_ = get_app_state() + @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): @@ -433,8 +462,17 @@ class CalibrationResponse(BaseModel): current_calibration: Optional[CalibrationFactors] = None # Initialize data providers +# Set CDS env vars so cdsapi.Client() picks them up +if settings.cdsapi_key: + os.environ.setdefault("CDSAPI_URL", settings.cdsapi_url) + os.environ.setdefault("CDSAPI_KEY", settings.cdsapi_key) + # Copernicus provider (attempts real API if configured) -copernicus_provider = CopernicusDataProvider(cache_dir="data/copernicus_cache") +copernicus_provider = CopernicusDataProvider( + cache_dir="data/copernicus_cache", + cmems_username=settings.copernicusmarine_service_username, + cmems_password=settings.copernicusmarine_service_password, +) # Climatology provider (for beyond-forecast-horizon) climatology_provider = ClimatologyProvider(cache_dir="data/climatology_cache") @@ -677,20 +715,68 @@ async def root(): @app.get("/api/health", tags=["System"]) async def health_check(): """ - Health check endpoint for load balancers and orchestrators. + Comprehensive health check endpoint for load balancers and orchestrators. + + Checks connectivity to all dependencies: + - Database (PostgreSQL) + - Cache (Redis) + - Weather data providers Returns: - - status: Service health status + - status: Overall health status (healthy/degraded/unhealthy) - timestamp: Current UTC timestamp - version: API version - - request_id: Correlation ID for tracing + - components: Individual component health status """ - return { - "status": "healthy", - "timestamp": datetime.utcnow().isoformat() + "Z", - "version": "2.1.0", - "request_id": get_request_id(), - } + from api.health import perform_full_health_check + result = await perform_full_health_check() + result["request_id"] = get_request_id() + return result + + +@app.get("/api/health/live", tags=["System"]) +async def liveness_check(): + """ + Kubernetes liveness probe endpoint. + + Simple check that the service is alive. + Use this for K8s livenessProbe configuration. + """ + from api.health import perform_liveness_check + return await perform_liveness_check() + + +@app.get("/api/health/ready", tags=["System"]) +async def readiness_check(): + """ + Kubernetes readiness probe endpoint. + + Checks if the service is ready to accept traffic. + Use this for K8s readinessProbe configuration. + """ + from api.health import perform_readiness_check + result = await perform_readiness_check() + + # Return 503 if not ready + if result.get("status") != "ready": + raise HTTPException(status_code=503, detail="Service not ready") + + return result + + +@app.get("/api/status", tags=["System"]) +async def detailed_status(): + """ + Detailed system status endpoint. + + Returns comprehensive information about the system including: + - Health status of all components + - Cache statistics + - Circuit breaker states + - Configuration summary + """ + from api.health import get_detailed_status + return await get_detailed_status() @app.get("/api/metrics", tags=["System"], response_class=PlainTextResponse) @@ -729,13 +815,15 @@ async def get_data_sources(): "copernicus": { "cds": { "available": copernicus_provider._has_cdsapi, + "configured": settings.has_cds_credentials, "description": "Climate Data Store (ERA5 wind data)", - "setup": "pip install cdsapi && create ~/.cdsapirc with API key", + "setup": "Set CDSAPI_KEY in .env (register at https://cds.climate.copernicus.eu)", }, "cmems": { "available": copernicus_provider._has_copernicusmarine, + "configured": settings.has_cmems_credentials, "description": "Copernicus Marine Service (waves, currents)", - "setup": "pip install copernicusmarine && configure credentials", + "setup": "Set COPERNICUSMARINE_SERVICE_USERNAME/PASSWORD in .env (register at https://marine.copernicus.eu)", }, "xarray": { "available": copernicus_provider._has_xarray, @@ -751,7 +839,8 @@ async def get_data_sources(): }, "active_source": "copernicus" if ( copernicus_provider._has_cdsapi and copernicus_provider._has_copernicusmarine - ) else "synthetic", + and (settings.has_cds_credentials or settings.has_cmems_credentials) + ) else "synthetic (no credentials configured — set CDSAPI_KEY and COPERNICUSMARINE_SERVICE_* in .env)", } @@ -876,7 +965,8 @@ async def api_get_wave_field( # High-resolution ocean mask (0.05° ≈ 5.5km) via vectorized numpy mask_lats, mask_lons, ocean_mask = _build_ocean_mask(lat_min, lat_max, lon_min, lon_max, step=0.05) - return { + # Build response with combined data + response = { "parameter": "wave_height", "time": time.isoformat(), "bbox": { @@ -900,9 +990,26 @@ async def api_get_wave_field( "min": 0, "max": 6, "colors": ["#00ff00", "#ffff00", "#ff8800", "#ff0000", "#800000"], - } + }, } + # Include wave decomposition when available + has_decomp = wave_data.windwave_height is not None and wave_data.swell_height is not None + response["has_decomposition"] = has_decomp + if has_decomp: + response["windwave"] = { + "height": wave_data.windwave_height.tolist(), + "period": wave_data.windwave_period.tolist() if wave_data.windwave_period is not None else None, + "direction": wave_data.windwave_direction.tolist() if wave_data.windwave_direction is not None else None, + } + response["swell"] = { + "height": wave_data.swell_height.tolist(), + "period": wave_data.swell_period.tolist() if wave_data.swell_period is not None else None, + "direction": wave_data.swell_direction.tolist() if wave_data.swell_direction is not None else None, + } + + return response + @app.get("/api/weather/currents") async def api_get_current_field( @@ -993,14 +1100,30 @@ async def api_get_weather_point( # ============================================================================ @app.post("/api/routes/parse-rtz") -async def parse_rtz(file: UploadFile = File(...)): +@limiter.limit(get_rate_limit_string()) +async def parse_rtz( + request: Request, + file: UploadFile = File(...), +): """ Parse an uploaded RTZ route file. + Maximum file size: 5 MB. Returns waypoints in standard format. """ try: content = await file.read() + + # Validate file size + if len(content) > MAX_RTZ_SIZE_BYTES: + raise HTTPException( + status_code=413, + detail=f"File too large. Maximum size: {MAX_RTZ_SIZE_BYTES // (1024*1024)} MB" + ) + + if len(content) == 0: + raise HTTPException(status_code=400, detail="Empty file") + rtz_string = content.decode('utf-8') route = parse_rtz_string(rtz_string) @@ -1369,24 +1492,38 @@ async def get_vessel_specs(): @app.post("/api/vessel/specs") -async def update_vessel_specs(config: VesselConfig): - """Update vessel specifications.""" +@limiter.limit(get_rate_limit_string()) +async def update_vessel_specs( + request: Request, + config: VesselConfig, + api_key=Depends(get_api_key), +): + """ + Update vessel specifications. + + Requires authentication via API key. + """ global current_vessel_specs, current_vessel_model, voyage_calculator try: - current_vessel_specs = VesselSpecs( - dwt=config.dwt, - loa=config.loa, - beam=config.beam, - draft_laden=config.draft_laden, - draft_ballast=config.draft_ballast, - mcr_kw=config.mcr_kw, - sfoc_at_mcr=config.sfoc_at_mcr, - service_speed_laden=config.service_speed_laden, - service_speed_ballast=config.service_speed_ballast, - ) - current_vessel_model = VesselModel(specs=current_vessel_specs) - voyage_calculator = VoyageCalculator(vessel_model=current_vessel_model) + # Use thread-safe state management + vessel_state = get_vessel_state() + vessel_state.update_specs({ + 'dwt': config.dwt, + 'loa': config.loa, + 'beam': config.beam, + 'draft_laden': config.draft_laden, + 'draft_ballast': config.draft_ballast, + 'mcr_kw': config.mcr_kw, + 'sfoc_at_mcr': config.sfoc_at_mcr, + 'service_speed_laden': config.service_speed_laden, + 'service_speed_ballast': config.service_speed_ballast, + }) + + # Update legacy globals for backward compatibility + current_vessel_specs = vessel_state.specs + current_vessel_model = vessel_state.model + voyage_calculator = vessel_state.voyage_calculator return {"status": "success", "message": "Vessel specs updated"} @@ -1432,8 +1569,17 @@ async def get_calibration(): @app.post("/api/vessel/calibration/set") -async def set_calibration_factors(factors: CalibrationFactorsModel): - """Manually set calibration factors.""" +@limiter.limit(get_rate_limit_string()) +async def set_calibration_factors( + request: Request, + factors: CalibrationFactorsModel, + api_key=Depends(get_api_key), +): + """ + Manually set calibration factors. + + Requires authentication via API key. + """ global current_calibration, current_vessel_model, voyage_calculator, route_optimizer current_calibration = CalibrationFactors( @@ -1446,17 +1592,14 @@ async def set_calibration_factors(factors: CalibrationFactorsModel): days_since_drydock=factors.days_since_drydock, ) - # Update vessel model with new calibration - current_vessel_model = VesselModel( - specs=current_vessel_specs, - calibration_factors={ - 'calm_water': current_calibration.calm_water, - 'wind': current_calibration.wind, - 'waves': current_calibration.waves, - } - ) - voyage_calculator = VoyageCalculator(vessel_model=current_vessel_model) - route_optimizer = RouteOptimizer(vessel_model=current_vessel_model) + # Use thread-safe state management + vessel_state = get_vessel_state() + vessel_state.update_calibration(current_calibration) + + # Update legacy globals for backward compatibility + current_vessel_model = vessel_state.model + voyage_calculator = vessel_state.voyage_calculator + route_optimizer = vessel_state.route_optimizer return {"status": "success", "message": "Calibration factors updated"} @@ -1484,8 +1627,17 @@ async def get_noon_reports(): @app.post("/api/vessel/noon-reports") -async def add_noon_report(report: NoonReportModel): - """Add a single noon report for calibration.""" +@limiter.limit(get_rate_limit_string()) +async def add_noon_report( + request: Request, + report: NoonReportModel, + api_key=Depends(get_api_key), +): + """ + Add a single noon report for calibration. + + Requires authentication via API key. + """ global vessel_calibrator nr = NoonReport( @@ -1514,10 +1666,18 @@ async def add_noon_report(report: NoonReportModel): @app.post("/api/vessel/noon-reports/upload-csv") -async def upload_noon_reports_csv(file: UploadFile = File(...)): +@limiter.limit("10/minute") # Lower rate limit for file uploads +async def upload_noon_reports_csv( + request: Request, + file: UploadFile = File(...), + api_key=Depends(get_api_key), +): """ Upload noon reports from CSV file. + Requires authentication via API key. + Maximum file size: 50 MB. + Expected columns: - timestamp (ISO format or common date format) - latitude, longitude @@ -1532,18 +1692,29 @@ async def upload_noon_reports_csv(file: UploadFile = File(...)): global vessel_calibrator try: + # Read and validate file size + content = await file.read() + if len(content) > MAX_CSV_SIZE_BYTES: + raise HTTPException( + status_code=413, + detail=f"File too large. Maximum size: {MAX_CSV_SIZE_BYTES // (1024*1024)} MB" + ) + + if len(content) == 0: + raise HTTPException(status_code=400, detail="Empty file") + # Save to temp file import tempfile with tempfile.NamedTemporaryFile(mode='wb', suffix='.csv', delete=False) as tmp: - content = await file.read() tmp.write(content) tmp_path = Path(tmp.name) - # Import from CSV - count = vessel_calibrator.add_noon_reports_from_csv(tmp_path) - - # Cleanup - tmp_path.unlink() + try: + # Import from CSV + count = vessel_calibrator.add_noon_reports_from_csv(tmp_path) + finally: + # Cleanup + tmp_path.unlink() return { "status": "success", @@ -1551,14 +1722,24 @@ async def upload_noon_reports_csv(file: UploadFile = File(...)): "total_reports": len(vessel_calibrator.noon_reports), } + except HTTPException: + raise except Exception as e: logger.error(f"Failed to import CSV: {e}", exc_info=True) raise HTTPException(status_code=400, detail=f"Failed to parse CSV: {str(e)}") @app.delete("/api/vessel/noon-reports") -async def clear_noon_reports(): - """Clear all uploaded noon reports.""" +@limiter.limit(get_rate_limit_string()) +async def clear_noon_reports( + request: Request, + api_key=Depends(get_api_key), +): + """ + Clear all uploaded noon reports. + + Requires authentication via API key. + """ global vessel_calibrator vessel_calibrator.noon_reports = [] @@ -1566,12 +1747,17 @@ async def clear_noon_reports(): @app.post("/api/vessel/calibrate", response_model=CalibrationResponse) +@limiter.limit("5/minute") # Lower limit for CPU-intensive operation async def calibrate_vessel( + request: Request, days_since_drydock: int = Query(0, ge=0, description="Days since last dry dock"), + api_key=Depends(get_api_key), ): """ Run calibration using uploaded noon reports. + Requires authentication via API key. + Finds optimal calibration factors that minimize prediction error compared to actual fuel consumption. """ @@ -1732,10 +1918,17 @@ async def get_zone(zone_id: str): @app.post("/api/zones", response_model=ZoneResponse) -async def create_zone(request: CreateZoneRequest): +@limiter.limit(get_rate_limit_string()) +async def create_zone( + http_request: Request, + request: CreateZoneRequest, + api_key=Depends(get_api_key), +): """ Create a custom zone. + Requires authentication via API key. + Coordinates should be provided as a list of {lat, lon} objects forming a closed polygon (first and last point should match). """ @@ -1798,10 +1991,16 @@ async def create_zone(request: CreateZoneRequest): @app.delete("/api/zones/{zone_id}") -async def delete_zone(zone_id: str): +@limiter.limit(get_rate_limit_string()) +async def delete_zone( + request: Request, + zone_id: str, + api_key=Depends(get_api_key), +): """ Delete a custom zone. + Requires authentication via API key. Built-in zones cannot be deleted. """ zone_checker = get_zone_checker() diff --git a/api/middleware.py b/api/middleware.py index a1b1baf..cfaa1c3 100644 --- a/api/middleware.py +++ b/api/middleware.py @@ -113,15 +113,20 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: "magnetometer=(), microphone=(), payment=(), usb=()" ) - # Content Security Policy + # Content Security Policy - Strict mode for production security + # Note: If you need inline scripts/styles, use nonces or hashes instead + # See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP response.headers["Content-Security-Policy"] = ( "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " - "style-src 'self' 'unsafe-inline'; " + "script-src 'self'; " + "style-src 'self'; " "img-src 'self' data: https:; " "font-src 'self' data:; " "connect-src 'self' https:; " - "frame-ancestors 'none';" + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'; " + "upgrade-insecure-requests;" ) # HSTS - only enable in production with HTTPS diff --git a/api/resilience.py b/api/resilience.py new file mode 100644 index 0000000..0788c0b --- /dev/null +++ b/api/resilience.py @@ -0,0 +1,365 @@ +""" +Resilience patterns for WINDMAR API. + +Provides circuit breakers, retry logic, and fallback mechanisms +for external service calls. +""" +import logging +import functools +import asyncio +from typing import TypeVar, Callable, Any, Optional +from datetime import datetime, timedelta +from enum import Enum +from dataclasses import dataclass, field +import threading + +from tenacity import ( + retry, + stop_after_attempt, + wait_exponential, + retry_if_exception_type, + before_sleep_log, + RetryError, +) + +logger = logging.getLogger(__name__) + +T = TypeVar('T') + + +class CircuitState(Enum): + """Circuit breaker states.""" + CLOSED = "closed" # Normal operation + OPEN = "open" # Failing, reject requests + HALF_OPEN = "half_open" # Testing if service recovered + + +@dataclass +class CircuitBreaker: + """ + Thread-safe circuit breaker implementation. + + Prevents cascading failures by stopping calls to failing services + and allowing them time to recover. + + Usage: + breaker = CircuitBreaker(name="copernicus_api") + + @breaker + def call_external_service(): + ... + """ + name: str + failure_threshold: int = 5 + recovery_timeout: int = 60 # seconds + half_open_max_calls: int = 3 + + _state: CircuitState = field(default=CircuitState.CLOSED, init=False) + _failure_count: int = field(default=0, init=False) + _success_count: int = field(default=0, init=False) + _last_failure_time: Optional[datetime] = field(default=None, init=False) + _lock: threading.RLock = field(default_factory=threading.RLock, init=False) + _half_open_calls: int = field(default=0, init=False) + + @property + def state(self) -> CircuitState: + """Get current circuit state.""" + with self._lock: + return self._state + + @property + def is_closed(self) -> bool: + """Check if circuit is closed (normal operation).""" + return self._check_state() == CircuitState.CLOSED + + @property + def is_open(self) -> bool: + """Check if circuit is open (rejecting calls).""" + return self._check_state() == CircuitState.OPEN + + def _check_state(self) -> CircuitState: + """Check and potentially transition circuit state.""" + with self._lock: + if self._state == CircuitState.OPEN: + # Check if recovery timeout has elapsed + if self._last_failure_time: + elapsed = (datetime.utcnow() - self._last_failure_time).total_seconds() + if elapsed >= self.recovery_timeout: + self._transition_to_half_open() + + return self._state + + def _transition_to_half_open(self): + """Transition to half-open state for testing.""" + self._state = CircuitState.HALF_OPEN + self._half_open_calls = 0 + logger.info(f"Circuit breaker '{self.name}' transitioning to HALF_OPEN") + + def _transition_to_open(self): + """Transition to open state.""" + self._state = CircuitState.OPEN + self._last_failure_time = datetime.utcnow() + logger.warning(f"Circuit breaker '{self.name}' OPENED after {self._failure_count} failures") + + def _transition_to_closed(self): + """Transition to closed state.""" + self._state = CircuitState.CLOSED + self._failure_count = 0 + self._success_count = 0 + logger.info(f"Circuit breaker '{self.name}' CLOSED - service recovered") + + def record_success(self): + """Record a successful call.""" + with self._lock: + self._success_count += 1 + + if self._state == CircuitState.HALF_OPEN: + self._half_open_calls += 1 + if self._half_open_calls >= self.half_open_max_calls: + self._transition_to_closed() + elif self._state == CircuitState.CLOSED: + # Reset failure count on success + self._failure_count = max(0, self._failure_count - 1) + + def record_failure(self, error: Exception): + """Record a failed call.""" + with self._lock: + self._failure_count += 1 + self._last_failure_time = datetime.utcnow() + + logger.warning(f"Circuit breaker '{self.name}' recorded failure: {error}") + + if self._state == CircuitState.HALF_OPEN: + # Any failure in half-open goes back to open + self._transition_to_open() + elif self._state == CircuitState.CLOSED: + if self._failure_count >= self.failure_threshold: + self._transition_to_open() + + def __call__(self, func: Callable[..., T]) -> Callable[..., T]: + """Decorator to wrap function with circuit breaker.""" + @functools.wraps(func) + def wrapper(*args, **kwargs) -> T: + state = self._check_state() + + if state == CircuitState.OPEN: + raise CircuitOpenError( + f"Circuit breaker '{self.name}' is OPEN. " + f"Service unavailable, try again in {self.recovery_timeout}s" + ) + + try: + result = func(*args, **kwargs) + self.record_success() + return result + except Exception as e: + self.record_failure(e) + raise + + return wrapper + + def call_async(self, func: Callable[..., T]) -> Callable[..., T]: + """Async decorator to wrap function with circuit breaker.""" + @functools.wraps(func) + async def wrapper(*args, **kwargs) -> T: + state = self._check_state() + + if state == CircuitState.OPEN: + raise CircuitOpenError( + f"Circuit breaker '{self.name}' is OPEN. " + f"Service unavailable, try again in {self.recovery_timeout}s" + ) + + try: + result = await func(*args, **kwargs) + self.record_success() + return result + except Exception as e: + self.record_failure(e) + raise + + return wrapper + + def get_status(self) -> dict: + """Get circuit breaker status.""" + with self._lock: + return { + 'name': self.name, + 'state': self._state.value, + 'failure_count': self._failure_count, + 'success_count': self._success_count, + 'last_failure': self._last_failure_time.isoformat() if self._last_failure_time else None, + 'failure_threshold': self.failure_threshold, + 'recovery_timeout_seconds': self.recovery_timeout, + } + + +class CircuitOpenError(Exception): + """Raised when circuit breaker is open.""" + pass + + +# Pre-configured circuit breakers for common services +copernicus_breaker = CircuitBreaker( + name="copernicus_api", + failure_threshold=3, + recovery_timeout=120, # 2 minutes +) + +external_api_breaker = CircuitBreaker( + name="external_api", + failure_threshold=5, + recovery_timeout=60, +) + + +def with_retry( + max_attempts: int = 3, + min_wait: float = 1.0, + max_wait: float = 30.0, + exceptions: tuple = (Exception,), +): + """ + Decorator for adding retry logic with exponential backoff. + + Args: + max_attempts: Maximum number of retry attempts + min_wait: Minimum wait time between retries (seconds) + max_wait: Maximum wait time between retries (seconds) + exceptions: Tuple of exception types to retry on + + Usage: + @with_retry(max_attempts=3, min_wait=1.0) + def call_external_service(): + ... + """ + def decorator(func: Callable[..., T]) -> Callable[..., T]: + @retry( + stop=stop_after_attempt(max_attempts), + wait=wait_exponential(multiplier=1, min=min_wait, max=max_wait), + retry=retry_if_exception_type(exceptions), + before_sleep=before_sleep_log(logger, logging.WARNING), + reraise=True, + ) + @functools.wraps(func) + def wrapper(*args, **kwargs) -> T: + return func(*args, **kwargs) + + return wrapper + + return decorator + + +def with_retry_async( + max_attempts: int = 3, + min_wait: float = 1.0, + max_wait: float = 30.0, + exceptions: tuple = (Exception,), +): + """ + Async decorator for adding retry logic with exponential backoff. + + Args: + max_attempts: Maximum number of retry attempts + min_wait: Minimum wait time between retries (seconds) + max_wait: Maximum wait time between retries (seconds) + exceptions: Tuple of exception types to retry on + """ + def decorator(func: Callable[..., T]) -> Callable[..., T]: + @retry( + stop=stop_after_attempt(max_attempts), + wait=wait_exponential(multiplier=1, min=min_wait, max=max_wait), + retry=retry_if_exception_type(exceptions), + before_sleep=before_sleep_log(logger, logging.WARNING), + reraise=True, + ) + @functools.wraps(func) + async def wrapper(*args, **kwargs) -> T: + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +def with_fallback(fallback_value: T = None, fallback_func: Callable = None): + """ + Decorator to provide fallback value or function on failure. + + Args: + fallback_value: Static value to return on failure + fallback_func: Function to call for fallback value (receives original args) + + Usage: + @with_fallback(fallback_value={"status": "unavailable"}) + def call_external_service(): + ... + + @with_fallback(fallback_func=get_cached_value) + def fetch_data(): + ... + """ + def decorator(func: Callable[..., T]) -> Callable[..., T]: + @functools.wraps(func) + def wrapper(*args, **kwargs) -> T: + try: + return func(*args, **kwargs) + except Exception as e: + logger.warning(f"Function {func.__name__} failed, using fallback: {e}") + + if fallback_func is not None: + return fallback_func(*args, **kwargs) + return fallback_value + + return wrapper + + return decorator + + +def with_timeout(seconds: float): + """ + Decorator to add timeout to synchronous functions. + + Note: Uses threading for timeout, may not interrupt all operations. + For async functions, use asyncio.timeout instead. + + Args: + seconds: Timeout in seconds + """ + def decorator(func: Callable[..., T]) -> Callable[..., T]: + @functools.wraps(func) + def wrapper(*args, **kwargs) -> T: + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor: + future = executor.submit(func, *args, **kwargs) + try: + return future.result(timeout=seconds) + except concurrent.futures.TimeoutError: + raise TimeoutError(f"Function {func.__name__} timed out after {seconds}s") + + return wrapper + + return decorator + + +# Registry for all circuit breakers (for health monitoring) +_circuit_breaker_registry: dict[str, CircuitBreaker] = {} + + +def register_circuit_breaker(breaker: CircuitBreaker): + """Register a circuit breaker for monitoring.""" + _circuit_breaker_registry[breaker.name] = breaker + + +def get_all_circuit_breaker_status() -> dict: + """Get status of all registered circuit breakers.""" + return { + name: breaker.get_status() + for name, breaker in _circuit_breaker_registry.items() + } + + +# Register default breakers +register_circuit_breaker(copernicus_breaker) +register_circuit_breaker(external_api_breaker) diff --git a/api/state.py b/api/state.py new file mode 100644 index 0000000..c594daa --- /dev/null +++ b/api/state.py @@ -0,0 +1,276 @@ +""" +Thread-safe state management for WINDMAR API. + +Provides proper locking and isolation for shared state in concurrent environments. +This replaces the unsafe global state pattern with a singleton that ensures +thread safety and proper initialization. +""" +import threading +import logging +from typing import Optional, Dict, Any +from dataclasses import dataclass, field +from datetime import datetime +from contextlib import contextmanager + +logger = logging.getLogger(__name__) + + +@dataclass +class VesselState: + """ + Thread-safe container for vessel-related state. + + Uses a lock to ensure atomic updates across all related objects + (specs, model, calculators). + """ + _lock: threading.RLock = field(default_factory=threading.RLock, repr=False) + + # Lazy imports to avoid circular dependencies + _specs: Any = None + _model: Any = None + _voyage_calculator: Any = None + _route_optimizer: Any = None + _calibrator: Any = None + _calibration: Any = None + + def __post_init__(self): + """Initialize with default vessel specs.""" + self._initialize_defaults() + + def _initialize_defaults(self): + """Initialize default vessel components.""" + from src.optimization.vessel_model import VesselModel, VesselSpecs + from src.optimization.voyage import VoyageCalculator + from src.optimization.route_optimizer import RouteOptimizer + from src.optimization.vessel_calibration import VesselCalibrator + + self._specs = VesselSpecs() + self._model = VesselModel(specs=self._specs) + self._voyage_calculator = VoyageCalculator(vessel_model=self._model) + self._route_optimizer = RouteOptimizer(vessel_model=self._model) + self._calibrator = VesselCalibrator(vessel_specs=self._specs) + self._calibration = None + + @property + def specs(self): + """Get vessel specs (thread-safe read).""" + with self._lock: + return self._specs + + @property + def model(self): + """Get vessel model (thread-safe read).""" + with self._lock: + return self._model + + @property + def voyage_calculator(self): + """Get voyage calculator (thread-safe read).""" + with self._lock: + return self._voyage_calculator + + @property + def route_optimizer(self): + """Get route optimizer (thread-safe read).""" + with self._lock: + return self._route_optimizer + + @property + def calibrator(self): + """Get calibrator (thread-safe read).""" + with self._lock: + return self._calibrator + + @property + def calibration(self): + """Get current calibration (thread-safe read).""" + with self._lock: + return self._calibration + + @contextmanager + def update_lock(self): + """ + Context manager for updating vessel state. + + Usage: + with vessel_state.update_lock(): + vessel_state.update_specs(new_specs) + """ + with self._lock: + yield self + + def update_specs(self, specs_dict: Dict[str, Any]) -> None: + """ + Update vessel specifications atomically. + + Args: + specs_dict: Dictionary of vessel specification parameters + """ + from src.optimization.vessel_model import VesselModel, VesselSpecs + from src.optimization.voyage import VoyageCalculator + from src.optimization.route_optimizer import RouteOptimizer + + with self._lock: + self._specs = VesselSpecs(**specs_dict) + self._model = VesselModel(specs=self._specs) + self._voyage_calculator = VoyageCalculator(vessel_model=self._model) + self._route_optimizer = RouteOptimizer(vessel_model=self._model) + + logger.info(f"Vessel specs updated: DWT={self._specs.dwt}") + + def update_calibration(self, calibration_factors: Any) -> None: + """ + Update calibration factors atomically. + + Args: + calibration_factors: CalibrationFactors instance + """ + from src.optimization.vessel_model import VesselModel + from src.optimization.voyage import VoyageCalculator + from src.optimization.route_optimizer import RouteOptimizer + + with self._lock: + self._calibration = calibration_factors + + # Rebuild model with calibration + self._model = VesselModel( + specs=self._specs, + calibration_factors={ + 'calm_water': calibration_factors.calm_water, + 'wind': calibration_factors.wind, + 'waves': calibration_factors.waves, + } + ) + self._voyage_calculator = VoyageCalculator(vessel_model=self._model) + self._route_optimizer = RouteOptimizer(vessel_model=self._model) + + logger.info("Vessel calibration updated") + + def get_snapshot(self) -> Dict[str, Any]: + """ + Get a snapshot of current state for read operations. + + Returns a copy that can be used without holding the lock. + """ + with self._lock: + return { + 'specs': self._specs, + 'model': self._model, + 'voyage_calculator': self._voyage_calculator, + 'route_optimizer': self._route_optimizer, + 'calibrator': self._calibrator, + 'calibration': self._calibration, + } + + +class ApplicationState: + """ + Singleton application state manager. + + Centralizes all shared state with proper thread safety. + Use get_app_state() to access the singleton instance. + """ + + _instance: Optional['ApplicationState'] = None + _lock: threading.Lock = threading.Lock() + + def __new__(cls): + """Ensure singleton pattern.""" + if cls._instance is None: + with cls._lock: + # Double-check locking + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + """Initialize application state (only once).""" + if self._initialized: + return + + self._initialized = True + self._vessel_state = VesselState() + self._weather_providers = None + self._startup_time = datetime.utcnow() + + logger.info("Application state initialized") + + @property + def vessel(self) -> VesselState: + """Get vessel state manager.""" + return self._vessel_state + + @property + def weather_providers(self): + """ + Get weather providers (lazy initialization). + + Returns tuple of (copernicus, climatology, unified, synthetic) + """ + if self._weather_providers is None: + self._initialize_weather_providers() + return self._weather_providers + + def _initialize_weather_providers(self): + """Initialize weather data providers.""" + from src.data.copernicus import ( + CopernicusDataProvider, + SyntheticDataProvider, + ClimatologyProvider, + UnifiedWeatherProvider, + ) + + copernicus = CopernicusDataProvider(cache_dir="data/copernicus_cache") + climatology = ClimatologyProvider(cache_dir="data/climatology_cache") + unified = UnifiedWeatherProvider( + copernicus=copernicus, + climatology=climatology, + cache_dir="data/weather_cache", + ) + synthetic = SyntheticDataProvider() + + self._weather_providers = { + 'copernicus': copernicus, + 'climatology': climatology, + 'unified': unified, + 'synthetic': synthetic, + } + + logger.info("Weather providers initialized") + + @property + def uptime_seconds(self) -> float: + """Get application uptime in seconds.""" + return (datetime.utcnow() - self._startup_time).total_seconds() + + def health_check(self) -> Dict[str, Any]: + """ + Perform health check on all components. + + Returns: + Dict with health status of each component + """ + return { + 'vessel_state': 'healthy' if self._vessel_state.specs is not None else 'unhealthy', + 'weather_providers': 'healthy' if self._weather_providers is not None else 'not_initialized', + 'uptime_seconds': self.uptime_seconds, + } + + +def get_app_state() -> ApplicationState: + """ + Get the application state singleton. + + This is the preferred way to access shared state throughout the application. + + Returns: + ApplicationState: The singleton application state instance + """ + return ApplicationState() + + +# Convenience aliases for backward compatibility +def get_vessel_state() -> VesselState: + """Get the vessel state manager.""" + return get_app_state().vessel diff --git a/frontend/README.md b/frontend/README.md index b880cf0..9d0b06a 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -219,7 +219,7 @@ npm run build ## License -Private - SL Mar +Apache 2.0 - See [LICENSE](../LICENSE) ## Support diff --git a/requirements.txt b/requirements.txt index 9dd3548..697810b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ +# ============================================================================= +# WINDMAR Dependencies +# ============================================================================= + # Core scientific computing numpy>=1.24.0 pandas>=2.0.0 @@ -17,26 +21,56 @@ global-land-mask>=1.0.0 # HTTP requests for data download requests>=2.31.0 +# Copernicus weather data +cdsapi>=0.7.0 +copernicusmarine>=2.0.0 +xarray>=2024.1.0 +netcdf4>=1.6.0 + # Excel file parsing openpyxl>=3.1.0 -# Database +# ============================================================================= +# Database & Caching +# ============================================================================= sqlalchemy>=2.0.0 +pydantic>=2.5.0 +pydantic-settings>=2.1.0 +redis>=5.0.0 -# API -fastapi>=0.100.0 -uvicorn>=0.23.0 -pydantic-settings>=2.0.0 +# ============================================================================= +# API Framework +# ============================================================================= +fastapi>=0.109.0 +uvicorn[standard]>=0.25.0 python-multipart>=0.0.6 -bcrypt>=4.0.0 -redis>=5.0.0 slowapi>=0.1.9 +# ============================================================================= +# Security +# ============================================================================= +defusedxml>=0.7.1 +bcrypt>=4.1.0 +python-jose[cryptography]>=3.3.0 + +# ============================================================================= +# Resilience & Observability +# ============================================================================= +tenacity>=8.2.0 +pybreaker>=1.0.0 +httpx>=0.26.0 + +# ============================================================================= # Testing +# ============================================================================= pytest>=7.4.0 pytest-cov>=4.1.0 +pytest-asyncio>=0.23.0 -# Development +# ============================================================================= +# Development & Code Quality +# ============================================================================= black>=23.0.0 mypy>=1.5.0 flake8>=6.1.0 +ruff>=0.1.0 diff --git a/scripts/create-github-issues.sh b/scripts/create-github-issues.sh new file mode 100755 index 0000000..5591b0a --- /dev/null +++ b/scripts/create-github-issues.sh @@ -0,0 +1,455 @@ +#!/bin/bash +# Create GitHub issues for WINDMAR open-source launch +# Run: gh auth login && bash scripts/create-github-issues.sh + +set -e + +echo "Creating labels..." + +gh label create "good-first-issue" --color "7057ff" --description "Good for newcomers" --force +gh label create "visualization" --color "0075ca" --description "Weather visualization features" --force +gh label create "bug" --color "d73a4a" --description "Something isn't working" --force +gh label create "critical" --color "b60205" --description "Critical priority" --force +gh label create "data-pipeline" --color "0e8a16" --description "Weather data sources and pipelines" --force +gh label create "help-wanted" --color "008672" --description "Extra attention is needed" --force +gh label create "physics" --color "e4e669" --description "Vessel model and naval architecture" --force +gh label create "phase-1" --color "c5def5" --description "Phase 1: Weather Visualization" --force +gh label create "phase-2" --color "bfdadc" --description "Phase 2: Fix Physics Engine" --force + +echo "Creating issues..." + +# Issue 1: Wind particles +gh issue create \ + --title "Add animated wind particles with leaflet-velocity" \ + --label "good-first-issue,visualization,phase-1" \ + --body "$(cat <<'EOF' +## Summary + +The backend already serves wind data in leaflet-velocity format. We need a React component that renders animated wind particles on the map. + +## What exists + +- `frontend/lib/api.ts:389` — `getWindVelocity()` already calls the endpoint +- `api/main.py:772` — `GET /api/weather/wind/velocity` returns grib2json format (U + V wind components) +- The endpoint is functional and returns properly formatted data + +## What to build + +1. Install `leaflet-velocity` (or `leaflet-velocity-ts` for TypeScript support) +2. Create `frontend/components/map/WindParticleLayer.tsx` +3. Wrap as a react-leaflet component using the `useMap()` hook +4. Color-code particles by wind speed (blue calm → red storm) +5. Dynamic import with `ssr: false` (Next.js + Leaflet requirement) + +## Key integration pattern + +```tsx +import { useEffect } from 'react'; +import { useMap } from 'react-leaflet'; +import L from 'leaflet'; +import 'leaflet-velocity'; + +export function WindParticleLayer({ data }) { + const map = useMap(); + useEffect(() => { + if (!data) return; + const layer = L.velocityLayer({ + displayValues: true, + data: data, + maxVelocity: 15, + velocityScale: 0.01, + }); + layer.addTo(map); + return () => { map.removeLayer(layer); }; + }, [map, data]); + return null; +} +``` + +## References + +- [leaflet-velocity tutorial](https://wlog.viltstigen.se/articles/2021/11/08/visualizing-wind-using-leaflet/) +- [react-leaflet integration pattern](https://kulkarniprem.hashnode.dev/how-to-create-custom-overlay-component-in-react-leaflet-using-leaflet-velocity) + +## Acceptance criteria + +- [ ] Animated particles render on the map +- [ ] Particles move according to wind direction +- [ ] Color reflects wind speed +- [ ] No SSR errors in Next.js +EOF +)" + +echo " ✓ Issue 1: Wind particles" + +# Issue 2: Wind resistance bug +gh issue create \ + --title "Bug: Wind resistance formula is inverted — following wind worse than head wind" \ + --label "bug,critical,physics,phase-2" \ + --body "$(cat <<'EOF' +## Bug description + +The vessel model calculates **7x more resistance** for following wind than head wind. This is physically backwards — following wind should produce thrust (negative resistance), not drag. + +## Failing test + +``` +tests/unit/test_vessel_model.py::test_head_wind_worse_than_following +``` + +Run it yourself: +```bash +pytest tests/unit/test_vessel_model.py::TestVesselModel::test_head_wind_worse_than_following -v +``` + +## Bug location + +`src/optimization/vessel_model.py` — `_wind_resistance()` method + +The `cx` aerodynamic coefficient yields: +- **0.2** at 0° relative angle (head wind) — should be the HIGHEST resistance +- **1.4** at 180° relative angle (following wind) — should produce THRUST + +Then `abs(cx)` is applied, treating both directions as pure drag. + +## Expected behavior + +A proper Blendermann-style wind coefficient should: +- Produce **positive drag** for head winds (0° relative) +- Produce **near-zero or negative (thrust)** for following winds (180° relative) +- Peak resistance around 30-60° relative angle (beam/quarter wind) + +## References + +- Blendermann, W. (1994) "Parameter identification of wind loads on ships" +- IMO MSC.1/Circ.1228 — Wind resistance coefficients + +## Impact + +This bug affects **every route optimization** — the A* cost function penalizes routes with favorable wind, which is the opposite of what weather routing should do. +EOF +)" + +echo " ✓ Issue 2: Wind resistance bug" + +# Issue 3: MCR cap bug +gh issue create \ + --title "Bug: MCR cap makes all fuel predictions identical regardless of conditions" \ + --label "bug,critical,physics,phase-2" \ + --body "$(cat <<'EOF' +## Bug description + +At service speed (~14.5 kts), the resistance model overestimates power so much that it hits the engine's maximum continuous rating (MCR) ceiling. Once capped, **every scenario produces identical fuel: 36.73 MT** — whether laden or ballast, calm or storm, 12 kts or 16 kts. + +## Failing tests (4 tests) + +```bash +pytest tests/unit/test_vessel_model.py -v -k "fuel_increases_with_speed or laden_uses_more or weather_impact or calibration_factors" +``` + +All produce the same output: +``` +fuel at 12 kts = 36.732852 MT +fuel at 14 kts = 36.732852 MT +fuel at 16 kts = 36.732852 MT +laden fuel = 36.732852 MT +ballast fuel = 36.732852 MT +calm fuel = 36.732852 MT +storm fuel = 36.732852 MT +``` + +## Root cause + +In `src/optimization/vessel_model.py`: +```python +brake_power_kw = min(brake_power_kw, self.specs.mcr_kw) # Clips to 8840 kW +``` + +At service speed, the resistance model produces a power demand that exceeds MCR. Once clipped, `load_fraction = 1.0` for all conditions, so SFOC and fuel are identical. + +## Expected behavior + +At service speed, the engine should operate at approximately **75% MCR load** — this is standard for commercial shipping. The resistance coefficients need recalibration: + +- Frictional resistance (ITTC 1957) — likely correct +- Form factor `k1` — simplified beyond recognition, `lcb_fraction` defined but unused +- Wave-making resistance — set to zero above Fn > 0.4 (should increase) + +## Fix approach + +Recalibrate the Holtrop-Mennen coefficients so that: +1. Service speed (14.5 kts, laden) → ~75% MCR +2. Slow steaming (10 kts) → ~30% MCR +3. Full speed (16 kts) → ~90% MCR + +These are typical values for an MR tanker with 8,840 kW MCR. + +## Impact + +This is the **most critical bug** in the codebase. Until fixed: +- Route optimization cannot distinguish fuel-efficient paths from wasteful ones +- Weather has zero effect on fuel predictions +- Calibration factors have no effect +- The entire optimization engine is effectively a shortest-distance pathfinder +EOF +)" + +echo " ✓ Issue 3: MCR cap bug" + +# Issue 4: Open-Meteo integration +gh issue create \ + --title "Add Open-Meteo as weather data source (no API key needed)" \ + --label "good-first-issue,data-pipeline,phase-1" \ + --body "$(cat <<'EOF' +## Summary + +We currently fall back to synthetic (fake) weather data because Copernicus requires package installation and credentials. Open-Meteo provides free, real weather data via a simple JSON API with no authentication. + +## What to build + +Create a new weather provider class in `src/data/` following the existing pattern: + +```python +class OpenMeteoProvider: + """Weather data from Open-Meteo (free, no API key).""" + + def get_wind(self, lat: float, lon: float, time: datetime) -> dict: + # GET https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}&hourly=wind_speed_10m,wind_direction_10m + ... + + def get_waves(self, lat: float, lon: float, time: datetime) -> dict: + # GET https://marine-api.open-meteo.com/v1/marine?latitude={lat}&longitude={lon}&hourly=wave_height,wave_period,wave_direction + ... +``` + +## Integration point + +- Look at `src/data/copernicus.py` — the `SyntheticDataProvider` class +- Create `src/data/open_meteo.py` with the same interface +- Add it to the fallback chain in `api/main.py`: Copernicus → **Open-Meteo** → Synthetic + +## API documentation + +- Wind/weather: https://open-meteo.com/en/docs +- Marine/waves: https://open-meteo.com/en/docs/marine-weather-api +- No API key required for non-commercial use +- Rate limit: fair use (~10,000 requests/day) + +## Acceptance criteria + +- [ ] Real wind speed/direction returned for any lat/lon +- [ ] Real wave height/period returned for ocean coordinates +- [ ] Graceful fallback to synthetic if Open-Meteo is unreachable +- [ ] Unit tests with mocked HTTP responses +EOF +)" + +echo " ✓ Issue 4: Open-Meteo integration" + +# Issue 5: Wave heatmap +gh issue create \ + --title "Add wave height heatmap overlay on map" \ + --label "good-first-issue,visualization,phase-1" \ + --body "$(cat <<'EOF' +## Summary + +The backend already serves wave height data. We need a color-coded heatmap overlay on the Leaflet map showing wave conditions. + +## What exists + +- `frontend/lib/api.ts:401` — `getWaveField()` calls `GET /api/weather/waves` +- Response includes a grid of wave heights with lat/lon bounds and resolution + +## What to build + +1. Create `frontend/components/map/WaveHeatmapLayer.tsx` +2. Render a semi-transparent Canvas overlay on the Leaflet map +3. Use bilinear interpolation between grid points for smooth rendering +4. Color ramp by wave height: + - Green: < 1m (calm) + - Yellow: 1-2m (moderate) + - Orange: 2-3m (rough) + - Red: 3-5m (very rough) + - Dark red: > 5m (high) +5. Opacity slider to blend with base map + +## Implementation approach + +Use Leaflet's `L.ImageOverlay` with a dynamically generated canvas: + +```tsx +const canvas = document.createElement('canvas'); +const ctx = canvas.getContext('2d'); +// For each grid cell, interpolate color from wave height +// Draw as rectangles on canvas +// Create image URL from canvas +const overlay = L.imageOverlay(canvas.toDataURL(), bounds); +``` + +## Acceptance criteria + +- [ ] Wave heights render as colored overlay on the map +- [ ] Colors correctly represent wave height ranges +- [ ] Overlay updates when time slider changes (future issue) +- [ ] Opacity is adjustable +- [ ] No SSR errors +EOF +)" + +echo " ✓ Issue 5: Wave heatmap" + +# Issue 6: Time slider +gh issue create \ + --title "Add time slider for forecast navigation" \ + --label "visualization,phase-1" \ + --body "$(cat <<'EOF' +## Summary + +Add a horizontal time slider at the bottom of the map that lets users scrub through weather forecast hours. This is a core feature of any Windy-like interface. + +## What to build + +1. Create `frontend/components/map/TimeSlider.tsx` +2. Horizontal slider spanning the forecast range (e.g., 0-120 hours) +3. Step through in 3h or 6h increments (matching GFS/Copernicus data) +4. Display current forecast time prominently (e.g., "Wed Feb 6, 18:00 UTC") +5. Play/pause button to animate through time steps +6. When slider changes, re-fetch wind/wave data with the new time parameter + +## Design reference + +Similar to Windy.com's bottom timeline bar — minimal, always visible, with a play button. + +## Backend support + +The velocity endpoint already accepts a \`time\` parameter: +``` +GET /api/weather/wind/velocity?time=2026-02-06T18:00:00 +``` + +## Acceptance criteria + +- [ ] Slider renders at bottom of map +- [ ] Moving slider updates the weather visualization +- [ ] Play button animates through time steps +- [ ] Current time is clearly displayed +- [ ] Pre-fetches adjacent time steps for smooth scrubbing +EOF +)" + +echo " ✓ Issue 6: Time slider" + +# Issue 7: NOAA GFS pipeline +gh issue create \ + --title "Add NOAA GFS wind data pipeline (free, no API key)" \ + --label "data-pipeline,phase-1" \ + --body "$(cat <<'EOF' +## Summary + +Connect to real global wind forecast data from NOAA's GFS model. This is the same data source that Windy.com uses. Free, updated every 6 hours, no API key needed. + +## Data source + +NOAA NOMADS GFS filter: https://nomads.ncep.noaa.gov/cgi-bin/filter_gfs_0p25.pl + +Select: +- Variables: \`UGRD\` (U-wind), \`VGRD\` (V-wind) +- Level: \`10 m above ground\` +- Resolution: 0.25° or 0.5° + +## What to build + +1. Create \`src/data/gfs_provider.py\` +2. Download GRIB2 files from NOMADS (filter URL to get only wind at 10m) +3. Convert to the grib2json format (same as \`/api/weather/wind/velocity\` expects) +4. Cache downloaded data with 6-hour TTL +5. Add as provider in the fallback chain + +## Conversion options + +- **pygrib** (Python): parse GRIB2 natively, extract U/V arrays +- **cfgrib + xarray** (Python): higher-level, handles coordinates automatically +- **grib2json** (Java CLI): used by leaflet-velocity ecosystem + +## File size + +- 0.25° global wind at one time step: ~5 MB GRIB2 +- 0.5° global: ~1.5 MB GRIB2 +- 1.0° global: ~400 KB GRIB2 + +## Acceptance criteria + +- [ ] Downloads latest GFS wind data automatically +- [ ] Converts to grib2json format consumed by leaflet-velocity +- [ ] Caches data to avoid redundant downloads +- [ ] Falls back gracefully if NOMADS is unreachable +- [ ] Unit tests with sample GRIB2 fixture +EOF +)" + +echo " ✓ Issue 7: GFS pipeline" + +# Issue 8: License — already resolved (Apache 2.0) +# The LICENSE file and all references have been aligned to Apache 2.0. +# Skipping issue creation. + +echo " ✓ Issue 8: License change" + +# Issue 9: .gitignore hardening +gh issue create \ + --title "Harden .gitignore before public release" \ + --label "good-first-issue,critical" \ + --body "$(cat <<'EOF' +## Summary + +The root \`.gitignore\` is missing entries for sensitive files. A \`git add .\` could accidentally commit secrets. + +## Missing entries to add + +\`\`\`gitignore +# Environment files (CRITICAL — currently missing!) +.env +.env.local +.env.*.local + +# Private keys and certificates +*.pem +*.key +*.p12 +*.pfx +*.cert +*.crt + +# SSL directory +docker/nginx/ssl/ + +# Logs +logs/ + +# Calibration state +data/calibration.json +\`\`\` + +## Context + +- The \`frontend/.gitignore\` correctly excludes \`.env.local\` but the **root** \`.gitignore\` does not exclude \`.env\` +- \`.dockerignore\` excludes \`.env\` but that only affects Docker builds, not git +- No secrets have been committed in git history (verified), but this gap should be closed + +## Acceptance criteria + +- [ ] \`.env\` added to root \`.gitignore\` +- [ ] All patterns above added +- [ ] Verified no \`.env\` files currently tracked: \`git ls-files | grep env\` +EOF +)" + +echo " ✓ Issue 9: .gitignore hardening" + +echo "" +echo "✅ All 9 issues created successfully!" +echo "" +echo "Summary:" +echo " Phase 1 (Weather Viz): Issues 1, 4, 5, 6, 7" +echo " Phase 2 (Physics): Issues 2, 3" +echo " Release Blockers: Issues 8, 9" diff --git a/src/data/copernicus.py b/src/data/copernicus.py index dd98936..a562f2f 100644 --- a/src/data/copernicus.py +++ b/src/data/copernicus.py @@ -40,10 +40,20 @@ class WeatherData: u_component: Optional[np.ndarray] = None v_component: Optional[np.ndarray] = None - # For wave data - additional fields + # For wave data - combined fields wave_period: Optional[np.ndarray] = None # Peak wave period (s) wave_direction: Optional[np.ndarray] = None # Mean wave direction (deg) + # Wave decomposition: wind-wave component + windwave_height: Optional[np.ndarray] = None # VHM0_WW (m) + windwave_period: Optional[np.ndarray] = None # VTM01_WW (s) + windwave_direction: Optional[np.ndarray] = None # VMDR_WW (deg) + + # Wave decomposition: primary swell component + swell_height: Optional[np.ndarray] = None # VHM0_SW1 (m) + swell_period: Optional[np.ndarray] = None # VTM01_SW1 (s) + swell_direction: Optional[np.ndarray] = None # VMDR_SW1 (deg) + @dataclass class PointWeather: @@ -59,6 +69,14 @@ class PointWeather: current_speed_ms: float = 0.0 current_dir_deg: float = 0.0 + # Wave decomposition + windwave_height_m: float = 0.0 + windwave_period_s: float = 0.0 + windwave_dir_deg: float = 0.0 + swell_height_m: float = 0.0 + swell_period_s: float = 0.0 + swell_dir_deg: float = 0.0 + class CopernicusDataProvider: """ @@ -88,15 +106,15 @@ def __init__( Args: cache_dir: Directory to cache downloaded data - cmems_username: CMEMS username (or set CMEMS_USERNAME env var) - cmems_password: CMEMS password (or set CMEMS_PASSWORD env var) + cmems_username: CMEMS username (or set COPERNICUSMARINE_SERVICE_USERNAME env var) + cmems_password: CMEMS password (or set COPERNICUSMARINE_SERVICE_PASSWORD env var) """ self.cache_dir = Path(cache_dir) self.cache_dir.mkdir(parents=True, exist_ok=True) - # CMEMS credentials - self.cmems_username = cmems_username or os.environ.get("CMEMS_USERNAME") - self.cmems_password = cmems_password or os.environ.get("CMEMS_PASSWORD") + # CMEMS credentials — resolve from param, then COPERNICUSMARINE_SERVICE_* env vars + self.cmems_username = cmems_username or os.environ.get("COPERNICUSMARINE_SERVICE_USERNAME") + self.cmems_password = cmems_password or os.environ.get("COPERNICUSMARINE_SERVICE_PASSWORD") # Cached xarray datasets self._wind_data: Optional[any] = None @@ -155,6 +173,10 @@ def fetch_wind_data( logger.warning("CDS API not available, returning None") return None + if not os.environ.get("CDSAPI_KEY"): + logger.warning("CDS API key not configured (set CDSAPI_KEY), returning None") + return None + import cdsapi import xarray as xr @@ -251,6 +273,10 @@ def fetch_wave_data( logger.warning("CMEMS API not available, returning None") return None + if not self.cmems_username or not self.cmems_password: + logger.warning("CMEMS credentials not configured, returning None") + return None + import copernicusmarine import xarray as xr @@ -272,15 +298,25 @@ def fetch_wave_data( try: ds = copernicusmarine.open_dataset( dataset_id=self.CMEMS_WAVE_DATASET, - variables=["VHM0", "VTPK", "VMDR"], # Hs, Peak period, Mean direction + variables=[ + "VHM0", "VTPK", "VMDR", # Combined: Hs, peak period, direction + "VHM0_WW", "VTM01_WW", "VMDR_WW", # Wind-wave component + "VHM0_SW1", "VTM01_SW1", "VMDR_SW1", # Primary swell component + ], minimum_longitude=lon_min, maximum_longitude=lon_max, minimum_latitude=lat_min, maximum_latitude=lat_max, start_datetime=start_time.strftime("%Y-%m-%dT%H:%M:%S"), end_datetime=(start_time + timedelta(hours=6)).strftime("%Y-%m-%dT%H:%M:%S"), + username=self.cmems_username, + password=self.cmems_password, ) + if ds is None: + logger.error("CMEMS returned None for wave data") + return None + # Save to cache ds.to_netcdf(cache_file) @@ -315,6 +351,28 @@ def fetch_wave_data( wave_dir = wave_dir[0] logger.info("Extracted wave direction (VMDR) from CMEMS") + # Extract wind-wave decomposition (optional — graceful if missing) + def _extract_var(name): + if name in ds: + v = ds[name].values + if len(v.shape) == 3: + v = v[0] + return v + return None + + ww_hs = _extract_var('VHM0_WW') + ww_tp = _extract_var('VTM01_WW') + ww_dir = _extract_var('VMDR_WW') + sw_hs = _extract_var('VHM0_SW1') + sw_tp = _extract_var('VTM01_SW1') + sw_dir = _extract_var('VMDR_SW1') + + has_decomp = ww_hs is not None and sw_hs is not None + if has_decomp: + logger.info("Extracted wind-wave/swell decomposition from CMEMS") + else: + logger.info("Swell decomposition not available in this dataset") + return WeatherData( parameter="wave_height", time=start_time, @@ -324,6 +382,12 @@ def fetch_wave_data( unit="m", wave_period=tp, wave_direction=wave_dir, + windwave_height=ww_hs, + windwave_period=ww_tp, + windwave_direction=ww_dir, + swell_height=sw_hs, + swell_period=sw_tp, + swell_direction=sw_dir, ) except Exception as e: @@ -353,6 +417,10 @@ def fetch_current_data( logger.warning("CMEMS API not available, returning None") return None + if not self.cmems_username or not self.cmems_password: + logger.warning("CMEMS credentials not configured, returning None") + return None + import copernicusmarine import xarray as xr @@ -381,8 +449,14 @@ def fetch_current_data( end_datetime=(start_time + timedelta(hours=6)).strftime("%Y-%m-%dT%H:%M:%S"), minimum_depth=0, maximum_depth=10, # Surface currents + username=self.cmems_username, + password=self.cmems_password, ) + if ds is None: + logger.error("CMEMS returned None for current data") + return None + ds.to_netcdf(cache_file) except Exception as e: @@ -626,7 +700,7 @@ def generate_wave_field( resolution: float = 1.0, wind_data: Optional[WeatherData] = None, ) -> WeatherData: - """Generate synthetic wave field based on wind.""" + """Generate synthetic wave field with wind-wave/swell decomposition.""" time = datetime.utcnow() lats = np.arange(lat_min, lat_max + resolution, resolution) @@ -634,14 +708,40 @@ def generate_wave_field( lon_grid, lat_grid = np.meshgrid(lons, lats) + # Wind-wave component: driven by local wind if wind_data is not None and wind_data.values is not None: wind_speed = wind_data.values - wave_height = 0.15 * wind_speed + np.random.randn(*wind_speed.shape) * 0.3 + ww_height = 0.12 * wind_speed + np.random.randn(*wind_speed.shape) * 0.2 + # Wind-wave direction follows wind direction + if wind_data.u_component is not None and wind_data.v_component is not None: + ww_dir = np.degrees(np.arctan2(-wind_data.u_component, -wind_data.v_component)) % 360 + else: + ww_dir = np.full_like(ww_height, 270.0) else: - wave_height = 1.5 + 1.0 * np.sin(np.radians(lat_grid * 3)) - wave_height += np.random.randn(*lat_grid.shape) * 0.2 - - wave_height = np.maximum(wave_height, 0.3) + ww_height = 0.8 + 0.5 * np.sin(np.radians(lat_grid * 3)) + ww_dir = np.full_like(ww_height, 270.0) + + ww_height = np.maximum(ww_height, 0.2) + ww_period = 3.0 + 0.8 * ww_height # Short-period wind sea + + # Swell component: long-period waves from distant storms + # Swell typically comes from a consistent direction, independent of local wind + swell_base = 1.0 + 0.8 * np.sin(np.radians(lat_grid * 2 + 30)) + sw_height = np.maximum(swell_base + np.random.randn(*lat_grid.shape) * 0.15, 0.3) + sw_period = 10.0 + 2.0 * sw_height # Long-period swell + sw_dir = np.full_like(sw_height, 300.0) + np.random.randn(*lat_grid.shape) * 5 # NW swell + + # Combined sea state (RSS of components) + wave_height = np.sqrt(ww_height**2 + sw_height**2) + # Combined period: energy-weighted + total_energy = ww_height**2 + sw_height**2 + wave_period = np.where( + total_energy > 0, + (ww_height**2 * ww_period + sw_height**2 * sw_period) / total_energy, + 8.0, + ) + # Combined direction: dominant component + wave_dir = np.where(sw_height > ww_height, sw_dir, ww_dir) return WeatherData( parameter="wave_height", @@ -650,6 +750,14 @@ def generate_wave_field( lons=lons, values=wave_height, unit="m", + wave_period=wave_period, + wave_direction=wave_dir % 360, + windwave_height=ww_height, + windwave_period=ww_period, + windwave_direction=ww_dir % 360, + swell_height=sw_height, + swell_period=sw_period, + swell_direction=sw_dir % 360, ) diff --git a/src/optimization/seakeeping.py b/src/optimization/seakeeping.py index d070f06..ad294be 100644 --- a/src/optimization/seakeeping.py +++ b/src/optimization/seakeeping.py @@ -288,6 +288,81 @@ def calculate_motions( encounter_frequency_rad=omega_e, ) + def calculate_motions_decomposed( + self, + windwave_height_m: float, + windwave_period_s: float, + windwave_dir_deg: float, + swell_height_m: float, + swell_period_s: float, + swell_dir_deg: float, + heading_deg: float, + speed_kts: float, + is_laden: bool, + ) -> MotionResponse: + """ + Calculate motions from separate wind-wave and swell systems. + + Computes response to each system independently, then combines + using spectral superposition (RSS for amplitudes, worst-case + for risk indicators). This is physically more accurate than + using a single combined sea state. + + Args: + windwave_height_m: Wind-wave significant height (m) + windwave_period_s: Wind-wave mean period (s) + windwave_dir_deg: Wind-wave direction (degrees, from) + swell_height_m: Primary swell height (m) + swell_period_s: Primary swell period (s) + swell_dir_deg: Primary swell direction (degrees, from) + heading_deg: Ship heading (degrees) + speed_kts: Ship speed (knots) + is_laden: Loading condition + + Returns: + MotionResponse with combined motion amplitudes + """ + # Calculate response to each wave system + ww_response = self.calculate_motions( + windwave_height_m, windwave_period_s, windwave_dir_deg, + heading_deg, speed_kts, is_laden + ) + sw_response = self.calculate_motions( + swell_height_m, swell_period_s, swell_dir_deg, + heading_deg, speed_kts, is_laden + ) + + # Combine using RSS (root sum of squares) for motion amplitudes. + # This approximates spectral superposition of independent systems. + combined_roll = math.sqrt(ww_response.roll_amplitude_deg**2 + sw_response.roll_amplitude_deg**2) + combined_pitch = math.sqrt(ww_response.pitch_amplitude_deg**2 + sw_response.pitch_amplitude_deg**2) + combined_heave = math.sqrt(ww_response.heave_accel_ms2**2 + sw_response.heave_accel_ms2**2) + combined_bow = math.sqrt(ww_response.bow_accel_ms2**2 + sw_response.bow_accel_ms2**2) + combined_bridge = math.sqrt(ww_response.bridge_accel_ms2**2 + sw_response.bridge_accel_ms2**2) + + # For risk indicators, take worst case + combined_slam = max(ww_response.slamming_probability, sw_response.slamming_probability) + combined_greenwater = max(ww_response.green_water_probability, sw_response.green_water_probability) + combined_param_roll = max(ww_response.parametric_roll_risk, sw_response.parametric_roll_risk) + + # Use the dominant system's encounter values (the one with larger roll) + dominant = sw_response if sw_response.roll_amplitude_deg > ww_response.roll_amplitude_deg else ww_response + + return MotionResponse( + roll_amplitude_deg=min(combined_roll, 45.0), + roll_period_s=dominant.roll_period_s, + pitch_amplitude_deg=min(combined_pitch, 20.0), + pitch_period_s=dominant.pitch_period_s, + heave_accel_ms2=combined_heave, + bow_accel_ms2=combined_bow, + bridge_accel_ms2=combined_bridge, + slamming_probability=combined_slam, + green_water_probability=combined_greenwater, + parametric_roll_risk=combined_param_roll, + encounter_period_s=dominant.encounter_period_s, + encounter_frequency_rad=dominant.encounter_frequency_rad, + ) + def _calculate_roll( self, wave_height_m: float, @@ -567,26 +642,51 @@ def assess_safety( heading_deg: float, speed_kts: float, is_laden: bool, + windwave_height_m: float = 0.0, + windwave_period_s: float = 0.0, + windwave_dir_deg: float = 0.0, + swell_height_m: float = 0.0, + swell_period_s: float = 0.0, + swell_dir_deg: float = 0.0, + has_decomposition: bool = False, ) -> SafetyAssessment: """ Perform full safety assessment for a voyage leg. + When wave decomposition is available, uses separate wind-wave + and swell systems for more accurate motion prediction. Falls + back to combined sea state when decomposition is not available. + Args: - wave_height_m: Significant wave height (m) - wave_period_s: Peak wave period (s) - wave_dir_deg: Wave direction (degrees) + wave_height_m: Significant wave height (m) — combined + wave_period_s: Peak wave period (s) — combined + wave_dir_deg: Wave direction (degrees) — combined heading_deg: Ship heading (degrees) speed_kts: Ship speed (knots) is_laden: Loading condition + windwave_height_m: Wind-wave height (m) — if decomposed + windwave_period_s: Wind-wave period (s) — if decomposed + windwave_dir_deg: Wind-wave direction (deg) — if decomposed + swell_height_m: Swell height (m) — if decomposed + swell_period_s: Swell period (s) — if decomposed + swell_dir_deg: Swell direction (deg) — if decomposed + has_decomposition: Whether decomposed data is available Returns: SafetyAssessment with detailed evaluation """ - # Calculate motions - motions = self.seakeeping.calculate_motions( - wave_height_m, wave_period_s, wave_dir_deg, - heading_deg, speed_kts, is_laden - ) + # Calculate motions — use decomposed data when available + if has_decomposition and windwave_height_m > 0 and swell_height_m > 0: + motions = self.seakeeping.calculate_motions_decomposed( + windwave_height_m, windwave_period_s, windwave_dir_deg, + swell_height_m, swell_period_s, swell_dir_deg, + heading_deg, speed_kts, is_laden, + ) + else: + motions = self.seakeeping.calculate_motions( + wave_height_m, wave_period_s, wave_dir_deg, + heading_deg, speed_kts, is_laden, + ) warnings = [] diff --git a/src/optimization/voyage.py b/src/optimization/voyage.py index afcc428..d6db9de 100644 --- a/src/optimization/voyage.py +++ b/src/optimization/voyage.py @@ -29,6 +29,15 @@ class LegWeather: current_speed_ms: float = 0.0 current_dir_deg: float = 0.0 + # Wave decomposition (when available from CMEMS) + windwave_height_m: float = 0.0 + windwave_period_s: float = 0.0 + windwave_dir_deg: float = 0.0 + swell_height_m: float = 0.0 + swell_period_s: float = 0.0 + swell_dir_deg: float = 0.0 + has_decomposition: bool = False + @dataclass class LegResult: diff --git a/src/routes/rtz_parser.py b/src/routes/rtz_parser.py index 22b409c..4303b39 100644 --- a/src/routes/rtz_parser.py +++ b/src/routes/rtz_parser.py @@ -3,15 +3,32 @@ Parses RTZ (Route Plan Exchange Format) files used by ECDIS systems. RTZ is an XML-based format defined by IEC 61174. + +Security Note: + Uses defusedxml to prevent XXE (XML External Entity) attacks. + Never use standard xml.etree.ElementTree for untrusted input. """ import logging -import xml.etree.ElementTree as ET from dataclasses import dataclass from pathlib import Path from typing import List, Optional, Tuple from datetime import datetime +# Use defusedxml to prevent XXE attacks +# See: https://owasp.org/www-community/vulnerabilities/XML_External_Entity_(XXE)_Processing +try: + import defusedxml.ElementTree as ET +except ImportError: + # Fallback with security warning - should never happen in production + import xml.etree.ElementTree as ET + import warnings + warnings.warn( + "defusedxml not installed! XML parsing is vulnerable to XXE attacks. " + "Install with: pip install defusedxml", + UserWarning + ) + logger = logging.getLogger(__name__)