From d274fe98222e92007bb139132288f593c4e1043c Mon Sep 17 00:00:00 2001 From: Roy Li Date: Mon, 20 Jul 2026 20:25:32 -0600 Subject: [PATCH 1/6] api: update api description, delete legacy controllers, imports --- .../app/controllers/era5_data_controller.py | 586 ------------------ .../app/controllers/wtk_data_controller.py | 540 ---------------- windwatts-api/app/main.py | 22 +- 3 files changed, 3 insertions(+), 1145 deletions(-) delete mode 100644 windwatts-api/app/controllers/era5_data_controller.py delete mode 100644 windwatts-api/app/controllers/wtk_data_controller.py diff --git a/windwatts-api/app/controllers/era5_data_controller.py b/windwatts-api/app/controllers/era5_data_controller.py deleted file mode 100644 index 00bfe18b..00000000 --- a/windwatts-api/app/controllers/era5_data_controller.py +++ /dev/null @@ -1,586 +0,0 @@ -from typing import List -from fastapi import APIRouter, HTTPException, Path, Query -from fastapi.responses import StreamingResponse -import zipfile -import tempfile -import re -import os -import io - -# commented out the data functions until I can get local athena_config working -from app.config_manager import ConfigManager -from app.data_fetchers.s3_data_fetcher import S3DataFetcher -from app.data_fetchers.athena_data_fetcher import AthenaDataFetcher - -# from app.data_fetchers.database_data_fetcher import DatabaseDataFetcher -from app.data_fetchers.data_fetcher_router import DataFetcherRouter - -# from app.database_manager import DatabaseManager -from app.utils.data_fetcher_utils import format_coordinate, chunker - -from app.power_curve.global_power_curve_manager import power_curve_manager -from app.schemas import ( - WindSpeedResponse, - AvailablePowerCurvesResponse, - EnergyProductionResponse, - NearestLocationsResponse, -) - -router = APIRouter() - -# Create router first, then optionally initialize heavy dependencies unless skipped -data_fetcher_router = DataFetcherRouter() -_skip_data_init = os.environ.get("SKIP_DATA_INIT", "0") == "1" -if not _skip_data_init: - # Initialize ConfigManager - config_manager = ConfigManager( - secret_arn_env_var="WINDWATTS_DATA_CONFIG_SECRET_ARN", - local_config_path="./app/config/windwatts_data_config.json", - ) # replace with YOUR local config path - athena_config = config_manager.get_config() - - # Initialize DataFetchers - # s3_data_fetcher = S3DataFetcher("WINDWATTS_S3_BUCKET_NAME") - athena_data_fetcher_era5 = AthenaDataFetcher( - athena_config=athena_config, source_key="era5" - ) - athena_data_fetcher_ensemble = AthenaDataFetcher( - athena_config=athena_config, source_key="ensemble" - ) - s3_data_fetcher_era5 = S3DataFetcher( - bucket_name="windwatts-era5", - prefix="era5_timeseries", - grid="era5", - s3_key_template="era5", - ) - # db_manager = DatabaseManager() - # db_data_fetcher = DatabaseDataFetcher(db_manager=db_manager) - - # # Initialize DataFetcherRouter and register fetchers - data_fetcher_router = DataFetcherRouter() - # data_fetcher_router.register_fetcher("database", db_data_fetcher) - data_fetcher_router.register_fetcher("s3_era5", s3_data_fetcher_era5) - data_fetcher_router.register_fetcher("athena_era5", athena_data_fetcher_era5) - data_fetcher_router.register_fetcher( - "athena_ensemble", athena_data_fetcher_ensemble - ) - -# Centralized valid avg types dictionary -VALID_AVG_TYPES = { - "athena_era5": { - "wind_speed": ["all", "annual", "none"], - "production": ["all", "summary", "annual", "full", "none"], - }, - "athena_ensemble": { - "wind_speed": ["all", "none"], - "production": ["all", "none"], - }, -} -# YEARS list for the sample data download feature -SAMPLE_YEARS = {"s3_era5": [2020, 2021, 2022, 2023]} - -# YEARS for which we have era5 data in the S3 -ALL_YEARS = {"s3_era5": list(range(2013, 2024))} - -# data_type='era5' -# data_source = "athena_era5" -VALID_SOURCES = {"athena_era5", "athena_ensemble", "s3_era5"} # <-- new -DEFAULT_SOURCE = "athena_era5" - - -# Helper validation functions -def validate_lat(lat: float) -> float: - if not (-90 <= lat <= 90): - raise HTTPException( - status_code=400, detail="Latitude must be between -90 and 90." - ) - return lat - - -def validate_lng(lng: float) -> float: - if not (-180 <= lng <= 180): - raise HTTPException( - status_code=400, detail="Longitude must be between -180 and 180." - ) - return lng - - -def validate_height(height: int) -> int: - if not (0 < height <= 300): - raise HTTPException( - status_code=400, detail="Height must be between 1 and 300 meters." - ) - return height - - -def validate_avg_type(avg_type: str, source: str) -> str: - allowed = VALID_AVG_TYPES[source]["wind_speed"] - if avg_type not in allowed: - raise HTTPException( - status_code=400, - detail=f"Invalid avg_type. Must be one of: {allowed} for {source}.", - ) - return avg_type - - -def validate_production_avg_type(avg_type: str, source: str) -> str: - allowed = VALID_AVG_TYPES[source]["production"] - if avg_type not in allowed: - raise HTTPException( - status_code=400, - detail=f"Invalid time_period. Must be one of: {allowed} for {source}.", - ) - return avg_type - - -def validate_selected_powercurve(selected_powercurve: str) -> str: - # Only allow alphanumeric, dash, underscore, dot - if not re.match(r"^[\w\-.]+$", selected_powercurve): - raise HTTPException(status_code=400, detail="Invalid selected_powercurve name.") - if selected_powercurve not in power_curve_manager.power_curves: - raise HTTPException(status_code=400, detail="Selected power curve not found.") - return selected_powercurve - - -def validate_source(source: str) -> str: - if source not in VALID_SOURCES: - raise HTTPException( - status_code=400, - detail=f"Invalid source for ERA5 data. Must be one of: {sorted(VALID_SOURCES)}.", - ) - return source - - -def validate_year(year: int, source: str) -> int: - if year not in ALL_YEARS[source]: - raise HTTPException( - status_code=400, - detail="Invalid year for ERA5 data. Currently supporting years 2013-2023", - ) - return year - - -def validate_n_neighbor(n_neighbor: int) -> int: - if not 1 <= n_neighbor <= 4: # have to change the limit if needed later on - raise HTTPException( - status_code=400, - detail="Invalid number of neighbors. Currently supporting upto 4 nearest neighbors", - ) - return n_neighbor - - -def _get_windspeed_core( - lat: float, lng: float, height: int, avg_type: str, source: str -): - """ - Core function to retrieve wind speed data from the source database. - Args: - lat (float): Latitude of the location. - lng (float): Longitude of the location. - height (int): Height in meters. - avg_type (str): Type of average to retrieve. Must be one of: global (default), monthly, yearly. - source (str): Source of the data. Must be one of: athena_, s3, database. - """ - lat = validate_lat(lat) - lng = validate_lng(lng) - height = validate_height(height) - source = validate_source(source) - avg_type = validate_avg_type(avg_type, source) - - # Legacy conversion: avg_type -> period for new API - params = {"lat": lat, "lng": lng, "height": height, "period": avg_type} - data = data_fetcher_router.fetch_data(params, key=source) - if data is None: - raise HTTPException(status_code=404, detail="Data not found") - return data - - -@router.get( - "/windspeed/{avg_type}", - summary="Retrieve wind speed with avg type - era5 data", - response_model=WindSpeedResponse, - responses={ - 200: { - "description": "Wind speed data retrieved successfully", - "model": WindSpeedResponse, - }, - 500: {"description": "Internal server error"}, - }, -) -def get_windspeed_with_avg_type( - avg_type: str = Path(..., description="Type of average to retrieve."), - lat: float = Query(..., description="Latitude of the location."), - lng: float = Query(..., description="Longitude of the location."), - height: int = Query(..., description="Height in meters."), - ensemble: bool = Query( - False, description="If true, use ensemble model (athena_ensemble)." - ), - source: str = Query(DEFAULT_SOURCE, description="Source of the data."), -): - try: - if ensemble: - return _get_windspeed_core( - lat, lng, height, avg_type, source="athena_ensemble" - ) - else: - return _get_windspeed_core(lat, lng, height, avg_type, source) - except HTTPException: - raise - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get( - "/windspeed", - summary="Retrieve wind speed with default global avg - era5 data", - response_model=WindSpeedResponse, - responses={ - 200: { - "description": "Wind speed data retrieved successfully", - "model": WindSpeedResponse, - }, - 500: {"description": "Internal server error"}, - }, -) -def get_windspeed( - lat: float = Query(..., description="Latitude of the location."), - lng: float = Query(..., description="Longitude of the location."), - height: int = Query(..., description="Height in meters."), - ensemble: bool = Query( - False, description="If true, use ensemble model (athena_ensemble)." - ), - period: str = Query("all", description="Time period for wind speed calculation."), - source: str = Query(DEFAULT_SOURCE, description="Source of the data."), -): - try: - if ensemble: - return _get_windspeed_core( - lat, lng, height, period, source="athena_ensemble" - ) - else: - return _get_windspeed_core(lat, lng, height, period, source) - except HTTPException: - raise - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get( - "/powercurves", - summary="Fetch all available power curves", - response_model=AvailablePowerCurvesResponse, - responses={ - 200: { - "description": "Available power curves retrieved successfully", - "model": AvailablePowerCurvesResponse, - }, - 500: {"description": "Internal server error"}, - }, -) -def fetch_available_powercurves(): - """ - returns available power curves - """ - try: - all_curves = list(power_curve_manager.power_curves.keys()) - prefix = "nlr-reference-" - - def extract_kw(curve_name: str): - # Extracts the kw value from curves, "2.5kW" -> 2.5 - match = re.search(rf"{prefix}([0-9.]+)kW", curve_name) - if match: - return float(match.group(1)) - return float("inf") - - curves = [c for c in all_curves if c.startswith(prefix)] - other_curves = [c for c in all_curves if not c.startswith(prefix)] - - curves_sorted = sorted(curves, key=extract_kw) - other_curves_sorted = sorted(other_curves) - - ordered_curves = curves_sorted + other_curves_sorted - return {"available_power_curves": ordered_curves} - except HTTPException: - raise - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -def _get_energy_production_core( - lat: float, lng: float, height: int, powercurve: str, period: str, source: str -): - """ - Fetches the global, yearly and monthly energy production and average windspeed for a given location, height, and power curve. - Args: - lat (float): Latitude of the location. - lng (float): Longitude of the location. - height (int): Height in meters. - power_curve(str): Selected powercurve/turbine. - period (str, optional): Time period to retrieve. Must be one of: global, yearly, monthly, all. - source (str): Source of the data. Must be one of: athena, s3, database. - Returns: - A JSON object containing average windspeeds and energy production at specified time period or global energy production when time period is not specified. - """ - lat = validate_lat(lat) - lng = validate_lng(lng) - height = validate_height(height) - selected_powercurve = validate_selected_powercurve(powercurve) - source = validate_source(source) - period = validate_production_avg_type(period, source) - params = {"lat": lat, "lng": lng, "height": height} - df = data_fetcher_router.fetch_raw(params, key=source) - if df is None: - raise HTTPException(status_code=404, detail="Data not found") - - if period == "all": - summary_avg_energy_production = ( - power_curve_manager.calculate_energy_production_summary( - df, height, selected_powercurve - ) - ) - return { - "energy_production": summary_avg_energy_production["Average year"][ - "kWh produced" - ] - } - - elif period == "summary": - summary_avg_energy_production = ( - power_curve_manager.calculate_energy_production_summary( - df, height, selected_powercurve - ) - ) - return {"summary_avg_energy_production": summary_avg_energy_production} - - elif period == "annual": - yearly_avg_energy_production = ( - power_curve_manager.calculate_yearly_energy_production( - df, height, selected_powercurve - ) - ) - return {"yearly_avg_energy_production": yearly_avg_energy_production} - - elif period == "full": - summary_avg_energy_production = ( - power_curve_manager.calculate_energy_production_summary( - df, height, selected_powercurve - ) - ) - yearly_avg_energy_production = ( - power_curve_manager.calculate_yearly_energy_production( - df, height, selected_powercurve - ) - ) - return { - "energy_production": summary_avg_energy_production["Average year"][ - "kWh produced" - ], - "summary_avg_energy_production": summary_avg_energy_production, - "yearly_avg_energy_production": yearly_avg_energy_production, - } - - -@router.get( - "/production/{period}", - summary="Get yearly and monthly energy production estimate and average windspeed for a location at a height with a selected power curve", - response_model=EnergyProductionResponse, - responses={ - 200: { - "description": "Energy production data retrieved successfully", - "model": EnergyProductionResponse, - }, - 500: {"description": "Internal server error"}, - }, -) -def energy_production_with_period( - period: str = Path(..., description="Time period for production estimate."), - lat: float = Query(..., description="Latitude of the location."), - lng: float = Query(..., description="Longitude of the location."), - height: int = Query(..., description="Height in meters."), - powercurve: str = Query(..., description="Selected power curve name."), - ensemble: bool = Query( - False, description="If true, use ensemble model (athena_ensemble)." - ), - source: str = Query(DEFAULT_SOURCE, description="Source of the data."), -): - try: - if ensemble: - return _get_energy_production_core( - lat, lng, height, powercurve, period, source="athena_ensemble" - ) - else: - return _get_energy_production_core( - lat, lng, height, powercurve, period, source - ) - except Exception: - raise HTTPException(status_code=500, detail="Internal server error.") - - -@router.get( - "/production", - summary="Get global energy production estimate for a location at a height with a selected power curve", - response_model=EnergyProductionResponse, - responses={ - 200: { - "description": "Energy production data retrieved successfully", - "model": EnergyProductionResponse, - }, - 500: {"description": "Internal server error"}, - }, -) -def energy_production( - lat: float = Query(..., description="Latitude of the location."), - lng: float = Query(..., description="Longitude of the location."), - height: int = Query(..., description="Height in meters."), - powercurve: str = Query(..., description="Selected power curve name."), - period: str = Query("all", description="Time period for production estimate."), - ensemble: bool = Query( - False, description="If true, use ensemble model (athena_ensemble)." - ), - source: str = Query(DEFAULT_SOURCE, description="Source of the data."), -): - try: - if ensemble: - return _get_energy_production_core( - lat, lng, height, powercurve, period, source="athena_ensemble" - ) - else: - return _get_energy_production_core( - lat, lng, height, powercurve, period, source - ) - except HTTPException: - raise - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -def _download_csv_core(gridIndices: List[str], years: List[int], source: str): - source = validate_source(source) - years = [validate_year(year, source) for year in years] - - params = {"gridIndices": gridIndices, "years": years} - - df = data_fetcher_router.fetch_data(params, key=source) - - if df is None or df.empty: - raise HTTPException( - status_code=404, detail="No data found for the specified parameters" - ) - - return df - - -@router.get( - "/timeseries", - summary="Download csv file for windspeed timeseries for a specific location for certain year(s) with 1 neighbor", -) -def download_timeseries_csv( - gridIndex: str = Query( - ..., description="Grid index with respect to user selected coordinate" - ), - years: List[int] = Query( - SAMPLE_YEARS["s3_era5"], description="years of which the data to download" - ), - source: str = Query("s3_era5", description="Source of the data."), -): - try: - # Getting DataFrame from core function - df = _download_csv_core([gridIndex], years, source) - - # Converting DataFrame to CSV - csv_io = io.StringIO() - df.to_csv(csv_io, index=False) - csv_io.seek(0) - - return StreamingResponse( - iter([csv_io.getvalue()]), media_type="text/csv; charset=utf-8" - ) - - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.post( - "/timeseries/batch", - summary="Download multiple CSVs (one per neighbor) as a streamed ZIP", -) -def download_timeseries_csv_batch( - payload: NearestLocationsResponse, - years: List[int] = Query( - SAMPLE_YEARS["s3_era5"], description="years of which the data to download" - ), - source: str = Query("s3_era5", description="Source of the data."), -): - try: - # Spooled file: stays in memory until threshold, then spills to disk automatically - spooled = tempfile.SpooledTemporaryFile( - max_size=30 * 1024 * 1024, mode="w+b" - ) # 30MB threshold (Each decompressed file is around 5.3 MB) - - with zipfile.ZipFile(spooled, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: - for loc in payload.locations: - df = _download_csv_core([loc.index], years, source) - csv_io = io.StringIO() - df.to_csv(csv_io, index=False) - csv_io.seek(0) - file_name = f"wind_data_{format_coordinate(loc.latitude)}_{format_coordinate(loc.longitude)}.csv" - zf.writestr(file_name, csv_io.getvalue()) - - spooled.seek(0) - - headers = { - "Content-Disposition": f'attachment; filename="wind_data_{len(payload.locations)}_points.zip"' - } - - return StreamingResponse( - chunker(spooled), media_type="application/zip", headers=headers - ) - - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get( - "/grid-points", - summary="Find nearest grid points", - response_model=NearestLocationsResponse, - responses={ - 200: {"description": "Nearest locations retrieved successfully"}, - 400: {"description": "Bad request"}, - 500: {"description": "Internal server error"}, - }, -) -def grid_points( - lat: float = Query(..., description="Latitude of the target location."), - lng: float = Query(..., description="Longitude of the target location."), - limit: int = Query(1, description="Number of nearest grid points."), - source: str = Query(DEFAULT_SOURCE, description="Source of the data"), -): - try: - lat = validate_lat(lat) - lng = validate_lng(lng) - limit = validate_n_neighbor(limit) - source = validate_source(source) - - grid_lookup_map = { - "athena_era5": athena_data_fetcher_era5, - } - - fetcher = grid_lookup_map.get(source) - if not fetcher: - raise HTTPException( - status_code=400, - detail=f"Nearest locations lookup not available for source='{source}'", - ) - - # Call find_nearest_locations on the Athena fetcher - result = fetcher.find_nearest_locations(lat=lat, lng=lng, n_neighbors=limit) - - locations = [ - {"index": str(i), "latitude": float(a), "longitude": float(o)} - for i, a, o in result - ] - - return {"locations": locations} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Internal server error: {e}") diff --git a/windwatts-api/app/controllers/wtk_data_controller.py b/windwatts-api/app/controllers/wtk_data_controller.py deleted file mode 100644 index 655179d5..00000000 --- a/windwatts-api/app/controllers/wtk_data_controller.py +++ /dev/null @@ -1,540 +0,0 @@ -from fastapi import APIRouter, HTTPException, Path, Query -import re -import os -from typing import List -import io -from fastapi.responses import StreamingResponse -import zipfile -import tempfile - -# commented out the data functions until I can get local athena_config working -from app.config_manager import ConfigManager -from app.data_fetchers.s3_data_fetcher import S3DataFetcher -from app.data_fetchers.athena_data_fetcher import AthenaDataFetcher - -# from app.data_fetchers.database_data_fetcher import DatabaseDataFetcher -from app.data_fetchers.data_fetcher_router import DataFetcherRouter - -# from app.database_manager import DatabaseManager -from app.utils.data_fetcher_utils import format_coordinate, chunker - -from app.power_curve.global_power_curve_manager import power_curve_manager -from app.schemas import ( - WindSpeedResponse, - AvailablePowerCurvesResponse, - EnergyProductionResponse, - NearestLocationsResponse, -) - -router = APIRouter() - -# Create router first, then optionally initialize heavy dependencies unless skipped -data_fetcher_router = DataFetcherRouter() -_skip_data_init = os.environ.get("SKIP_DATA_INIT", "0") == "1" -if not _skip_data_init: - # Initialize ConfigManager - config_manager = ConfigManager( - secret_arn_env_var="WINDWATTS_DATA_CONFIG_SECRET_ARN", - local_config_path="./app/config/windwatts_data_config.json", - ) # replace with YOUR local config path - athena_config = config_manager.get_config() - - # Initialize DataFetchers - s3_data_fetcher_wtk = S3DataFetcher( - bucket_name="wtk-led", prefix="1224", grid="wtk", s3_key_template="wtk" - ) - athena_data_fetcher_wtk = AthenaDataFetcher( - athena_config=athena_config, source_key="wtk" - ) - # db_manager = DatabaseManager() - # db_data_fetcher = DatabaseDataFetcher(db_manager=db_manager) - - # Register fetchers - # data_fetcher_router.register_fetcher("database", db_data_fetcher) - data_fetcher_router.register_fetcher("s3_wtk", s3_data_fetcher_wtk) - data_fetcher_router.register_fetcher("athena_wtk", athena_data_fetcher_wtk) - -VALID_AVG_TYPES = { - "athena_wtk": { - "wind_speed": ["all", "yearly", "monthly", "hourly", "none"], - "production": ["all", "summary", "yearly", "monthly", "none"], - } -} - -# YEARS list for the sample data download feature -SAMPLE_YEARS = {"s3_wtk": [2018, 2019, 2020]} - -# YEARS for which we have wtk data in the S3 -ALL_YEARS = {"s3_wtk": list(range(2000, 2021))} -# data_type = "wtk" -VALID_SOURCES = {"athena_wtk", "s3_wtk"} # <-- new -DEFAULT_SOURCE = "athena_wtk" - - -# Helper validation functions -def validate_lat(lat: float) -> float: - if not (-90 <= lat <= 90): - raise HTTPException( - status_code=400, detail="Latitude must be between -90 and 90." - ) - return lat - - -def validate_lng(lng: float) -> float: - if not (-180 <= lng <= 180): - raise HTTPException( - status_code=400, detail="Longitude must be between -180 and 180." - ) - return lng - - -def validate_height(height: int) -> int: - if not (0 < height <= 300): - raise HTTPException( - status_code=400, detail="Height must be between 1 and 300 meters." - ) - return height - - -def validate_avg_type(avg_type: str, source: str) -> str: - allowed = VALID_AVG_TYPES[source]["wind_speed"] - if avg_type not in allowed: - raise HTTPException( - status_code=400, - detail=f"Invalid avg_type. Must be one of: {allowed} for {source}.", - ) - return avg_type - - -def validate_production_avg_type(avg_type: str, source: str) -> str: - allowed = VALID_AVG_TYPES[source]["production"] - if avg_type not in allowed: - raise HTTPException( - status_code=400, - detail=f"Invalid time_period. Must be one of: {allowed} for {source}.", - ) - return avg_type - - -def validate_selected_powercurve(selected_powercurve: str) -> str: - if not re.match(r"^[\w\-.]+$", selected_powercurve): - raise HTTPException(status_code=400, detail="Invalid selected_powercurve name.") - if selected_powercurve not in power_curve_manager.power_curves: - raise HTTPException(status_code=400, detail="Selected power curve not found.") - return selected_powercurve - - -def validate_source(source: str) -> str: - if source not in VALID_SOURCES: - raise HTTPException( - status_code=400, - detail=f"Invalid source for WTK data. Must be one of: {sorted(VALID_SOURCES)}.", - ) - return source - - -def validate_year(year: int, source: str) -> int: - if year not in ALL_YEARS[source]: - raise HTTPException( - status_code=400, - detail="Invalid year for WTK data. Currently supporting years 2000-2022", - ) - return year - - -def validate_n_neighbor(n_neighbor: int) -> int: - if not 1 <= n_neighbor <= 4: # have to change the limit if needed later on - raise HTTPException( - status_code=400, - detail="Invalid number of neighbors. Currently supporting upto 4 nearest neighbors", - ) - return n_neighbor - - -def _get_windspeed_core( - lat: float, lng: float, height: int, avg_type: str, source: str -): - """ - Core function to retrieve wind speed data from the source database. - Args: - lat (float): Latitude of the location. - lng (float): Longitude of the location. - height (int): Height in meters. - avg_type (str): Type of average to retrieve. Must be one of: global (default), monthly, yearly. - source (str): Source of the data. Must be one of: athena_, s3, database. - """ - lat = validate_lat(lat) - lng = validate_lng(lng) - height = validate_height(height) - source = validate_source(source) - avg_type = validate_avg_type(avg_type, source) - - params = {"lat": lat, "lng": lng, "height": height, "period": avg_type} - data = data_fetcher_router.fetch_data(params, key=source) - if data is None: - raise HTTPException(status_code=404, detail="Data not found") - return data - - -@router.get( - "/windspeed/{avg_type}", - summary="Retrieve wind speed with avg type - wtk data", - response_model=WindSpeedResponse, - responses={ - 200: { - "description": "Wind speed data retrieved successfully", - "model": WindSpeedResponse, - }, - 500: {"description": "Internal server error"}, - }, -) -def get_windspeed_with_avg_type( - avg_type: str = Path(..., description="Type of average to retrieve."), - lat: float = Query(..., description="Latitude of the location."), - lng: float = Query(..., description="Longitude of the location."), - height: int = Query(..., description="Height in meters."), - source: str = Query(DEFAULT_SOURCE, description="Source of the data."), -): - try: - return _get_windspeed_core(lat, lng, height, avg_type, source) - except HTTPException: - raise - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get( - "/windspeed", - summary="Retrieve wind speed with default global avg - wtk data", - response_model=WindSpeedResponse, - responses={ - 200: { - "description": "Wind speed data retrieved successfully", - "model": WindSpeedResponse, - }, - 500: {"description": "Internal server error"}, - }, -) -def get_windspeed( - lat: float = Query(..., description="Latitude of the location."), - lng: float = Query(..., description="Longitude of the location."), - height: int = Query(..., description="Height in meters."), - source: str = Query(DEFAULT_SOURCE, description="Source of the data."), -): - try: - return _get_windspeed_core(lat, lng, height, "all", source) - except HTTPException: - raise - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get( - "/available-powercurves", - summary="Fetch all available power curves", - response_model=AvailablePowerCurvesResponse, - responses={ - 200: { - "description": "Available power curves retrieved successfully", - "model": AvailablePowerCurvesResponse, - }, - 500: {"description": "Internal server error"}, - }, -) -def fetch_available_powercurves(): - try: - all_curves = list(power_curve_manager.power_curves.keys()) - prefix = "nlr-reference-" - - def extract_kw(curve_name: str): - import re - - match = re.search(rf"{prefix}([0-9.]+)kW", curve_name) - if match: - return float(match.group(1)) - return float("inf") - - curves = [c for c in all_curves if c.startswith(prefix)] - other_curves = [c for c in all_curves if not c.startswith(prefix)] - curves_sorted = sorted(curves, key=extract_kw) - other_curves_sorted = sorted(other_curves) - ordered_curves = curves_sorted + other_curves_sorted - return {"available_power_curves": ordered_curves} - except HTTPException: - raise - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -def _get_energy_production_core( - lat: float, - lng: float, - height: int, - selected_powercurve: str, - time_period: str, - source: str, -): - """ - Fetches the global, yearly and monthly energy production and average windspeed for a given location, height, and power curve. - Args: - lat (float): Latitude of the location. - lng (float): Longitude of the location. - height (int): Height in meters. - time_period (str, optional): Time period to retrieve. Must be one of: global, yearly, monthly, all. - source (str): Source of the data. Must be one of: athena, s3, database. - Returns: - A JSON object containing average windspeeds and energy production at specified time period or global energy production when time period is not specified. - """ - lat = validate_lat(lat) - lng = validate_lng(lng) - height = validate_height(height) - selected_powercurve = validate_selected_powercurve(selected_powercurve) - source = validate_source(source) - time_period = validate_production_avg_type(time_period, source) - - params = {"lat": lat, "lng": lng, "height": height} - df = data_fetcher_router.fetch_raw(params, key=source) - if df is None: - raise HTTPException(status_code=404, detail="Data not found") - - if time_period == "all": - summary_avg_energy_production = ( - power_curve_manager.calculate_energy_production_summary( - df, height, selected_powercurve - ) - ) - return { - "energy_production": summary_avg_energy_production["Average year"][ - "kWh produced" - ] - } - - elif time_period == "summary": - summary_avg_energy_production = ( - power_curve_manager.calculate_energy_production_summary( - df, height, selected_powercurve - ) - ) - return {"summary_avg_energy_production": summary_avg_energy_production} - - elif time_period == "yearly": - yearly_avg_energy_production = ( - power_curve_manager.calculate_yearly_energy_production( - df, height, selected_powercurve - ) - ) - return {"yearly_avg_energy_production": yearly_avg_energy_production} - - elif time_period == "full": - summary_avg_energy_production = ( - power_curve_manager.calculate_energy_production_summary( - df, height, selected_powercurve - ) - ) - yearly_avg_energy_production = ( - power_curve_manager.calculate_yearly_energy_production( - df, height, selected_powercurve - ) - ) - return { - "energy_production": summary_avg_energy_production["Average year"][ - "kWh produced" - ], - "summary_avg_energy_production": summary_avg_energy_production, - "yearly_avg_energy_production": yearly_avg_energy_production, - } - - -@router.get( - "/energy-production/{time_period}", - summary="Get yearly and monthly energy production estimate and average windspeed for a location at a height with a selected power curve", - response_model=EnergyProductionResponse, - responses={ - 200: { - "description": "Energy production data retrieved successfully", - "model": EnergyProductionResponse, - }, - 500: {"description": "Internal server error"}, - }, -) -def energy_production_with_period( - time_period: str = Path(..., description="Time period for production estimate."), - lat: float = Query(..., description="Latitude of the location."), - lng: float = Query(..., description="Longitude of the location."), - height: int = Query(..., description="Height in meters."), - selected_powercurve: str = Query(..., description="Selected power curve name."), - source: str = Query(DEFAULT_SOURCE, description="Source of the data."), -): - try: - return _get_energy_production_core( - lat, lng, height, selected_powercurve, time_period, source - ) - except HTTPException: - raise - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get( - "/energy-production", - summary="Get global energy production estimate for a location at a height with a selected power curve", - response_model=EnergyProductionResponse, - responses={ - 200: { - "description": "Energy production data retrieved successfully", - "model": EnergyProductionResponse, - }, - 500: {"description": "Internal server error"}, - }, -) -def energy_production( - lat: float = Query(..., description="Latitude of the location."), - lng: float = Query(..., description="Longitude of the location."), - height: int = Query(..., description="Height in meters."), - selected_powercurve: str = Query(..., description="Selected power curve name."), - source: str = Query(DEFAULT_SOURCE, description="Source of the data."), -): - try: - return _get_energy_production_core( - lat, lng, height, selected_powercurve, "all", source - ) - except HTTPException: - raise - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -def _download_csv_core(gridIndices: List[str], years: List[int], source: str): - source = validate_source(source) - years = [validate_year(year, source) for year in years] - - params = {"gridIndices": gridIndices, "years": years} - - df = data_fetcher_router.fetch_data(params, key=source) - - if df is None or df.empty: - raise HTTPException( - status_code=404, detail="No data found for the specified parameters" - ) - - return df - - -@router.get( - "/download-csv", - summary="Download csv file for windspeed for a specific location for certain year(s) with 1 neighbor", -) -def download_csv( - gridIndex: str = Query( - ..., description="Grid index with respect to user selected coordinate" - ), - years: List[int] = Query( - SAMPLE_YEARS["s3_wtk"], description="years of which the data to download" - ), - source: str = Query("s3_wtk", description="Source of the data."), -): - try: - # Getting DataFrame from core function - df = _download_csv_core([gridIndex], years, source) - - # Converting DataFrame to CSV - csv_io = io.StringIO() - df.to_csv(csv_io, index=False) - csv_io.seek(0) - - return StreamingResponse( - iter([csv_io.getvalue()]), media_type="text/csv; charset=utf-8" - ) - - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.post( - "/download-csv-batch", - summary="Download multiple CSVs (one per neighbor) as a streamed ZIP", -) -def download_csv_batch( - payload: NearestLocationsResponse, - years: List[int] = Query( - SAMPLE_YEARS["s3_wtk"], description="years of which the data to download" - ), - source: str = Query("s3_wtk", description="Source of the data."), -): - try: - # Spooled file: stays in memory until threshold, then spills to disk automatically - spooled = tempfile.SpooledTemporaryFile( - max_size=30 * 1024 * 1024, mode="w+b" - ) # 30MB threshold - - with zipfile.ZipFile(spooled, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: - for loc in payload.locations: - df = _download_csv_core([loc.index], years, source) - csv_io = io.StringIO() - df.to_csv(csv_io, index=False) - csv_io.seek(0) - file_name = f"wind_data_{format_coordinate(loc.latitude)}_{format_coordinate(loc.longitude)}.csv" - zf.writestr(file_name, csv_io.getvalue()) - - spooled.seek(0) - - headers = { - "Content-Disposition": f'attachment; filename="wind_data_{len(payload.locations)}_points.zip"' - } - - return StreamingResponse( - chunker(spooled), media_type="application/zip", headers=headers - ) - - except Exception: - raise HTTPException(status_code=500, detail="Internal server error") - - -@router.get( - "/nearest-locations", - summary="Find nearest grid locations", - response_model=NearestLocationsResponse, - responses={ - 200: {"description": "Nearest locations retrieved successfully"}, - 400: {"description": "Bad request"}, - 500: {"description": "Internal server error"}, - }, -) -def nearest_locations( - lat: float = Query(..., description="Latitude of the target location."), - lng: float = Query(..., description="Longitude of the target location."), - n_neighbors: int = Query( - 1, description="Number of nearest grid points.", ge=1, le=4 - ), - source: str = Query(DEFAULT_SOURCE, description="Source of the data"), -): - try: - lat = validate_lat(lat) - lng = validate_lng(lng) - n_neighbors = validate_n_neighbor(n_neighbors) - source = validate_source(source) - - grid_lookup_map = { - "athena_wtk": athena_data_fetcher_wtk, - } - - fetcher = grid_lookup_map.get(source) - if not fetcher: - raise HTTPException( - status_code=400, - detail=f"Grid lookup not available for source='{source}'", - ) - - # Call find_nearest_locations directly on the Athena fetcher - result = fetcher.find_nearest_locations( - lat=lat, lng=lng, n_neighbors=n_neighbors - ) - - locations = [ - {"index": str(i), "latitude": float(a), "longitude": float(o)} - for i, a, o in result - ] - - return {"locations": locations} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Internal server error: {e}") diff --git a/windwatts-api/app/main.py b/windwatts-api/app/main.py index 383d588f..a41a3e4d 100644 --- a/windwatts-api/app/main.py +++ b/windwatts-api/app/main.py @@ -4,8 +4,6 @@ from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from mangum import Mangum -from app.controllers.wtk_data_controller import router as wtk_data_router -from app.controllers.era5_data_controller import router as era5_data_router from app.controllers.wind_data_controller import router as wind_data_router from app.middleware import AuditMiddleware, LoggingMiddleware from app.exception_handlers import log_unhandled_exceptions, log_validation_errors @@ -13,7 +11,7 @@ app = FastAPI( title="WindWatts API", - version="1.0.0", + version="2.0.0", root_path="/api", description=dedent( """ @@ -23,17 +21,12 @@ - Base path: `/api` - Contact: windwatts@nrel.gov - ## API Versions + ## API - **v1 (Recommended)**: - `/v1/{model}/windspeed` - Wind speed data - `/v1/{model}/production` - Energy production estimates - `/v1/{model}/timeseries` - Raw timeseries downloads - - Supports models: `era5-quantiles`, `era5-timeseries`, `wtk-timeseries`, `ensemble-quantiles` - - **Legacy**: Model-specific endpoints (deprecated) - - `/wtk/*` - WTK-specific endpoints - - `/era5/*` - ERA5-specific endpoints + - Supported models: `era5-quantiles`, `era5-timeseries`, `wtk-timeseries`, `ensemble-quantiles` Use the endpoints below to retrieve wind resource and production estimates. """ @@ -68,15 +61,6 @@ # API v1 app.include_router(wind_data_router, prefix="/v1", tags=["v1-wind-data"]) -# Legacy routes - Deprecated -# TODO: Remove these routes -app.include_router( - wtk_data_router, prefix="/wtk", tags=["wtk-data (deprecated)"], deprecated=True -) -app.include_router( - era5_data_router, prefix="/era5", tags=["era5-data (deprecated)"], deprecated=True -) - @app.get("/healthcheck", response_model=HealthCheckResponse) def healthcheck(): From c431b68028e24b36ec4f54c1d1fa8330c78923c0 Mon Sep 17 00:00:00 2001 From: Roy Li Date: Mon, 20 Jul 2026 20:26:27 -0600 Subject: [PATCH 2/6] api: update backend doc --- docs/03-backend.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/03-backend.md b/docs/03-backend.md index 41adc740..6095bd7e 100644 --- a/docs/03-backend.md +++ b/docs/03-backend.md @@ -62,3 +62,15 @@ pytest ## API Documentation When the app is running, visit `/docs` (e.g., `http://localhost:8080/docs`) to see the auto-generated Swagger UI. + +### API Endpoints + +All endpoints are under the `/api/v1/{model}/` prefix. + +| Endpoint | Description | +|---|---| +| `/v1/{model}/windspeed` | Wind speed data | +| `/v1/{model}/production` | Energy production estimates | +| `/v1/{model}/timeseries` | Raw timeseries downloads | + +**Supported models**: `era5-quantiles`, `era5-timeseries`, `wtk-timeseries`, `ensemble-quantiles` From 684527b9cf253913aba600a5c32ca2c8f53019a3 Mon Sep 17 00:00:00 2001 From: Roy Li Date: Mon, 20 Jul 2026 20:27:47 -0600 Subject: [PATCH 3/6] api-test: update controller tests --- .../tests/test_wind_data_controller.py | 38 ++----------------- 1 file changed, 3 insertions(+), 35 deletions(-) diff --git a/windwatts-api/tests/test_wind_data_controller.py b/windwatts-api/tests/test_wind_data_controller.py index 142890c9..22b6e67b 100644 --- a/windwatts-api/tests/test_wind_data_controller.py +++ b/windwatts-api/tests/test_wind_data_controller.py @@ -1,42 +1,10 @@ from fastapi.testclient import TestClient from app.main import app -# from unittest.mock import patch client = TestClient(app) -# uncomment these when i can get local athena_config working -""" -# this patches the fetch_data method on the data_fetcher_router instance -def test_get_wtk_data_success(): - # Fake data to be returned by the mocked fetch_data call. - fake_data = {"global_avg": 5.5} - # Patch the fetch_data method on the data_fetcher_router instance. - with patch("app.controllers.wind_data_controller.data_fetcher_router.fetch_data", return_value=fake_data): - response = client.get("/wtk-data?lat=40.0&lng=-70.0&height=10&source=athena") - assert response.status_code == 200 - assert response.json() == fake_data -# this patches the fetch_data method on the data_fetcher_router instance -def test_get_wtk_data_failure(): - # Patch the fetch_data method on the data_fetcher_router instance to raise an exception. - with patch("app.controllers.wind_data_controller.data_fetcher_router.fetch_data", side_effect=Exception("Test exception")): - response = client.get("/wtk-data?lat=40.0&lng=-70.0&height=10&source=athena") - assert response.status_code == 500 - assert response.json() == {"detail": "Test exception"} -""" - - -def test_get_wtk_data_success(): - response = client.get( - "/wtk/windspeed?lat=40.0&lng=-70.0&height=10&source=athena_wtk" - ) - assert response.status_code == 200 - json = response.json() - assert "global_avg" in json - - -def test_get_available_power_curves(): - response = client.get("/wtk/available-powercurves") +def test_healthcheck(): + response = client.get("/healthcheck") assert response.status_code == 200 - json = response.json() - assert "available_power_curves" in json + assert response.json() == {"status": "up"} From f09ceded289953defecf84502b749d7e038e4518 Mon Sep 17 00:00:00 2001 From: Roy Li Date: Mon, 20 Jul 2026 20:28:02 -0600 Subject: [PATCH 4/6] make format --- windwatts-ui/public/gtm-init.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/windwatts-ui/public/gtm-init.js b/windwatts-ui/public/gtm-init.js index 29986aa7..6b518af8 100644 --- a/windwatts-ui/public/gtm-init.js +++ b/windwatts-ui/public/gtm-init.js @@ -1,10 +1,10 @@ (function (w, d, s, l, i) { w[l] = w[l] || []; - w[l].push({ 'gtm.start': new Date().getTime(), event: 'gtm.js' }); + w[l].push({ "gtm.start": new Date().getTime(), event: "gtm.js" }); var f = d.getElementsByTagName(s)[0], j = d.createElement(s), - dl = l != 'dataLayer' ? '&l=' + l : ''; + dl = l != "dataLayer" ? "&l=" + l : ""; j.async = true; - j.src = 'https://www.googletagmanager.com/gtm.js?id=' + i + dl; + j.src = "https://www.googletagmanager.com/gtm.js?id=" + i + dl; f.parentNode.insertBefore(j, f); -})(window, document, 'script', 'dataLayer', 'GTM-KG7PVKB'); +})(window, document, "script", "dataLayer", "GTM-KG7PVKB"); From e7b8b76e3aa77a30cac867076f61ca92d77ed711 Mon Sep 17 00:00:00 2001 From: Roy Li Date: Mon, 20 Jul 2026 20:51:56 -0600 Subject: [PATCH 5/6] docs: add CHANGELOG, migration doc --- CHANGELOG.md | 23 +++++++++++++++++++++++ docs/06-migration.md | 34 ++++++++++++++++++++++++++++++++++ docs/README.md | 1 + 3 files changed, 58 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 docs/06-migration.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..472d4769 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [2.0.0] - 2026-07-20 + +### Breaking Changes + +- **Removed legacy API endpoints** (`/wtk/*` and `/era5/*`). These routes were deprecated in v1 and have now been removed. + - Migrate to the unified `/v1/{model}/` endpoints. See the [Migration Guide](docs/06-migration.md). + +### Changed + +- API version bumped from `1.0.0` to `2.0.0`. +- Removed orphaned controller files: `wtk_data_controller.py`, `era5_data_controller.py`. + +--- + +## [1.0.0] - Initial release + +- Introduced unified `/v1/{model}/` API endpoints. +- Legacy model-specific routes (`/wtk/*`, `/era5/*`) marked deprecated. +- Supported models: `era5-quantiles`, `era5-timeseries`, `wtk-timeseries`, `ensemble-quantiles`. diff --git a/docs/06-migration.md b/docs/06-migration.md new file mode 100644 index 00000000..5d49eda9 --- /dev/null +++ b/docs/06-migration.md @@ -0,0 +1,34 @@ +# API Migration Guide: Legacy --> v1 + +The legacy model-specific endpoints (`/wtk/*`, `/era5/*`) were removed in API v2.0.0. All functionality is available through the unified v1 API. + +For full endpoint details and parameters, see the **interactive API docs** at `/api/docs` when the app is running. + +## Route Structure + +``` +Legacy: /wtk/ → /v1/wtk-timeseries/ +Legacy: /era5/ → /v1/era5-quantiles/ +``` + +## Endpoint Mapping + +| Legacy | v1 Equivalent | +|---|---| +| `GET /wtk/windspeed` | `GET /v1/wtk-timeseries/windspeed` | +| `GET /wtk/energy-production` | `GET /v1/wtk-timeseries/production` | +| `GET /wtk/download-csv` | `GET /v1/wtk-timeseries/timeseries` | +| `POST /wtk/download-csv-batch` | `POST /v1/wtk-timeseries/timeseries/batch` | +| `GET /wtk/nearest-locations` | `GET /v1/wtk-timeseries/grid-points` | +| `GET /wtk/available-powercurves` | `GET /v1/turbines` | +| `GET /era5/windspeed` | `GET /v1/era5-quantiles/windspeed` | +| `GET /era5/production` | `GET /v1/era5-quantiles/production` | +| `GET /era5/timeseries` | `GET /v1/era5-timeseries/timeseries` | +| `POST /era5/timeseries/batch` | `POST /v1/era5-timeseries/timeseries/batch` | +| `GET /era5/grid-points` | `GET /v1/era5-quantiles/grid-points` | +| `GET /era5/powercurves` | `GET /v1/turbines` | + +## Notable Changes + +- **Period** — path-based period (e.g. `/windspeed/{avg_type}`) is now a query parameter: `?period=`. +- **Turbine** — the `powercurve` query parameter is renamed to `turbine`. diff --git a/docs/README.md b/docs/README.md index f64221d0..e961970d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ Welcome to the WindWatts documentation. 3. [Backend Guide](03-backend.md) - API development. 4. [Frontend Guide](04-frontend.md) - UI development. 5. [Deployment](05-deployment.md) - Production deployment. +6. [Migration Guide](06-migration.md) - Migrating from legacy API endpoints to v1. ## Contributing From f29eca4bcece019d0d92d6f8d9adb1a0ab292737 Mon Sep 17 00:00:00 2001 From: Roy Li Date: Mon, 20 Jul 2026 21:04:57 -0600 Subject: [PATCH 6/6] api-doc: update to full api path --- docs/03-backend.md | 6 ++-- docs/06-migration.md | 30 +++++++++---------- windwatts-api/app/main.py | 8 +++-- .../tests/test_wind_data_controller.py | 21 ++++++++++--- 4 files changed, 40 insertions(+), 25 deletions(-) diff --git a/docs/03-backend.md b/docs/03-backend.md index 6095bd7e..0a8c90a7 100644 --- a/docs/03-backend.md +++ b/docs/03-backend.md @@ -69,8 +69,8 @@ All endpoints are under the `/api/v1/{model}/` prefix. | Endpoint | Description | |---|---| -| `/v1/{model}/windspeed` | Wind speed data | -| `/v1/{model}/production` | Energy production estimates | -| `/v1/{model}/timeseries` | Raw timeseries downloads | +| `GET /api/v1/{model}/windspeed` | Wind speed data | +| `GET /api/v1/{model}/production` | Energy production estimates | +| `GET /api/v1/{model}/timeseries` | Raw timeseries downloads | **Supported models**: `era5-quantiles`, `era5-timeseries`, `wtk-timeseries`, `ensemble-quantiles` diff --git a/docs/06-migration.md b/docs/06-migration.md index 5d49eda9..281b66c0 100644 --- a/docs/06-migration.md +++ b/docs/06-migration.md @@ -7,28 +7,28 @@ For full endpoint details and parameters, see the **interactive API docs** at `/ ## Route Structure ``` -Legacy: /wtk/ → /v1/wtk-timeseries/ -Legacy: /era5/ → /v1/era5-quantiles/ +Legacy: /api/wtk/ → /api/v1/wtk-timeseries/ +Legacy: /api/era5/ → /api/v1/era5-quantiles/ ``` ## Endpoint Mapping | Legacy | v1 Equivalent | |---|---| -| `GET /wtk/windspeed` | `GET /v1/wtk-timeseries/windspeed` | -| `GET /wtk/energy-production` | `GET /v1/wtk-timeseries/production` | -| `GET /wtk/download-csv` | `GET /v1/wtk-timeseries/timeseries` | -| `POST /wtk/download-csv-batch` | `POST /v1/wtk-timeseries/timeseries/batch` | -| `GET /wtk/nearest-locations` | `GET /v1/wtk-timeseries/grid-points` | -| `GET /wtk/available-powercurves` | `GET /v1/turbines` | -| `GET /era5/windspeed` | `GET /v1/era5-quantiles/windspeed` | -| `GET /era5/production` | `GET /v1/era5-quantiles/production` | -| `GET /era5/timeseries` | `GET /v1/era5-timeseries/timeseries` | -| `POST /era5/timeseries/batch` | `POST /v1/era5-timeseries/timeseries/batch` | -| `GET /era5/grid-points` | `GET /v1/era5-quantiles/grid-points` | -| `GET /era5/powercurves` | `GET /v1/turbines` | +| `GET /api/wtk/windspeed` | `GET /api/v1/wtk-timeseries/windspeed` | +| `GET /api/wtk/energy-production` | `GET /api/v1/wtk-timeseries/production` | +| `GET /api/wtk/download-csv` | `GET /api/v1/wtk-timeseries/timeseries` | +| `POST /api/wtk/download-csv-batch` | `POST /api/v1/wtk-timeseries/timeseries/batch` | +| `GET /api/wtk/nearest-locations` | `GET /api/v1/wtk-timeseries/grid-points` | +| `GET /api/wtk/available-powercurves` | `GET /api/v1/turbines` | +| `GET /api/era5/windspeed` | `GET /api/v1/era5-quantiles/windspeed` | +| `GET /api/era5/production` | `GET /api/v1/era5-quantiles/production` | +| `GET /api/era5/timeseries` | `GET /api/v1/era5-timeseries/timeseries` | +| `POST /api/era5/timeseries/batch` | `POST /api/v1/era5-timeseries/timeseries/batch` | +| `GET /api/era5/grid-points` | `GET /api/v1/era5-quantiles/grid-points` | +| `GET /api/era5/powercurves` | `GET /api/v1/turbines` | ## Notable Changes - **Period** — path-based period (e.g. `/windspeed/{avg_type}`) is now a query parameter: `?period=`. -- **Turbine** — the `powercurve` query parameter is renamed to `turbine`. +- **Turbine** — the `powercurve` query parameter is deprecated and the renamed and recommended query parameter is `turbine`. diff --git a/windwatts-api/app/main.py b/windwatts-api/app/main.py index a41a3e4d..ef51554b 100644 --- a/windwatts-api/app/main.py +++ b/windwatts-api/app/main.py @@ -23,11 +23,13 @@ ## API - - `/v1/{model}/windspeed` - Wind speed data - - `/v1/{model}/production` - Energy production estimates - - `/v1/{model}/timeseries` - Raw timeseries downloads + - `GET /api/v1/{model}/windspeed` - Wind speed data + - `GET /api/v1/{model}/production` - Energy production estimates + - `GET /api/v1/{model}/timeseries` - Raw timeseries downloads - Supported models: `era5-quantiles`, `era5-timeseries`, `wtk-timeseries`, `ensemble-quantiles` + Full interactive documentation: `/api/docs` + Use the endpoints below to retrieve wind resource and production estimates. """ ).strip(), diff --git a/windwatts-api/tests/test_wind_data_controller.py b/windwatts-api/tests/test_wind_data_controller.py index 22b6e67b..1cf98245 100644 --- a/windwatts-api/tests/test_wind_data_controller.py +++ b/windwatts-api/tests/test_wind_data_controller.py @@ -4,7 +4,20 @@ client = TestClient(app) -def test_healthcheck(): - response = client.get("/healthcheck") - assert response.status_code == 200 - assert response.json() == {"status": "up"} +def test_legacy_wtk_routes_removed(): + """Verify that legacy /wtk/* routes are no longer served (sunset in API v2.0.0).""" + assert client.get("/wtk/windspeed?lat=40.0&lng=-100.0&height=80").status_code == 404 + assert ( + client.get("/wtk/energy-production?lat=40.0&lng=-100.0&height=80").status_code + == 404 + ) + assert client.get("/wtk/nearest-locations?lat=40.0&lng=-100.0").status_code == 404 + + +def test_legacy_era5_routes_removed(): + """Verify that legacy /era5/* routes are no longer served (sunset in API v2.0.0).""" + assert client.get("/era5/windspeed?lat=40.0&lng=-70.0&height=40").status_code == 404 + assert ( + client.get("/era5/production?lat=40.0&lng=-70.0&height=40").status_code == 404 + ) + assert client.get("/era5/grid-points?lat=40.0&lng=-70.0").status_code == 404