From 17c9aa865a9be88b8e2dc4c4bc5e0e34058d41cd Mon Sep 17 00:00:00 2001 From: erdemerikci Date: Wed, 3 Jun 2026 16:16:27 +0300 Subject: [PATCH] Add Mapbox token configuration to docker-compose and implement turbine information table generation in report. Enhance visualization functions with satellite basemap integration and improve lightning size scaling in coordinate plane plots. --- report_service/docker-compose.yml | 3 + src/reporting/docx.py | 17 +--- src/reporting/docx_sections.py | 51 ++++++++++ src/visualization/basemap.py | 149 ++++++++++++++++++++++++++++++ src/visualization/maps.py | 44 ++++++--- src/visualization/storm_cells.py | 9 +- 6 files changed, 243 insertions(+), 30 deletions(-) create mode 100644 src/visualization/basemap.py diff --git a/report_service/docker-compose.yml b/report_service/docker-compose.yml index 88af3ad..d3a405f 100644 --- a/report_service/docker-compose.yml +++ b/report_service/docker-compose.yml @@ -28,6 +28,9 @@ services: # service to call Gemini itself. - GEMINI_API_KEY=${GEMINI_API_KEY:-} - GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash} + # Mapbox token for the satellite basemap behind the coordinate-plane charts. + # Charts fall back to a plain background when unset. + - MAPBOX_TOKEN=${MAPBOX_TOKEN:-} ports: - "8000:8000" networks: diff --git a/src/reporting/docx.py b/src/reporting/docx.py index e9ad2f2..588d6d9 100644 --- a/src/reporting/docx.py +++ b/src/reporting/docx.py @@ -24,6 +24,7 @@ from src.reporting.docx_sections import ( _build_current_vs_distance_chart, build_lightning_event_table_data, build_risk_table_data, + build_turbine_information_table_data, ) from src.utils import ( format_datetime_ddmmyyyy_hhmm, @@ -514,21 +515,7 @@ def create_docx_report( # Turbine information _add_title(doc, "Turbine Information", size_pt=16, align=WD_ALIGN_PARAGRAPH.LEFT) _add_paragraph(doc, "This table contains detailed information about all turbines in the wind farm.", size_pt=10) - turb_rows: list[list[str]] = [[ - "Turbine", "Lat", "Lng", "Unit Power (MWm)", "Unit Power (MWe)", "Tower Height (m)", "Rotor Diameter (m)", "Altitude (m)", - ]] - for _, t in turbine_df.iterrows(): - turb_rows.append([ - str(t.get("name", "N/A")), - f"{t.get('lat', 0):.4f}", - f"{t.get('lng', 0):.4f}", - str(t.get("unit_power_mwm", "N/A")), - str(t.get("unit_power_mwe", "N/A")), - str(t.get("tower_height_m", "N/A")), - str(t.get("turbine_rotor_blade_diameter", "N/A")), - str(t.get("altitude", "N/A")), - ]) - _add_table(doc, turb_rows) + _add_table(doc, build_turbine_information_table_data(turbine_df)) # Gemini commentary (single API call per report run; falls back deterministically if Gemini is unavailable) analysis_radius_km = float(get_analysis_radius_m()) / 1000.0 if get_analysis_radius_m() > 0 else float(max(config.distance_rings) / 1000.0) diff --git a/src/reporting/docx_sections.py b/src/reporting/docx_sections.py index 019847f..9c99e48 100644 --- a/src/reporting/docx_sections.py +++ b/src/reporting/docx_sections.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Any, Callable + import numpy as np import pandas as pd import plotly.graph_objects as go @@ -166,6 +168,55 @@ def _build_current_vs_distance_chart( return fig +def _turbine_cell_value_is_available(value: Any) -> bool: + if value is None: + return False + try: + if pd.isna(value): + return False + except (TypeError, ValueError): + pass + text = str(value).strip() + if not text: + return False + if text.lower() in {"n/a", "na", "nan", "none", "-", "--"}: + return False + return True + + +def build_turbine_information_table_data( + turbine_df: pd.DataFrame, +) -> list[list[str]]: + column_specs: list[tuple[str, str, Callable[[pd.Series], str], bool]] = [ + ("Turbine", "name", lambda t: str(t.get("name", "N/A")), True), + ("Lat", "lat", lambda t: f"{t.get('lat', 0):.4f}", True), + ("Lng", "lng", lambda t: f"{t.get('lng', 0):.4f}", True), + ("Unit Power (MWm)", "unit_power_mwm", lambda t: str(t.get("unit_power_mwm", "N/A")), False), + ("Unit Power (MWe)", "unit_power_mwe", lambda t: str(t.get("unit_power_mwe", "N/A")), False), + ("Tower Height (m)", "tower_height_m", lambda t: str(t.get("tower_height_m", "N/A")), False), + ( + "Rotor Diameter (m)", + "turbine_rotor_blade_diameter", + lambda t: str(t.get("turbine_rotor_blade_diameter", "N/A")), + False, + ), + ("Altitude (m)", "altitude", lambda t: str(t.get("altitude", "N/A")), False), + ] + + active_columns: list[tuple[str, Callable[[pd.Series], str]]] = [] + for header, field, formatter, always_include in column_specs: + if always_include: + active_columns.append((header, formatter)) + continue + if any(_turbine_cell_value_is_available(row.get(field)) for _, row in turbine_df.iterrows()): + active_columns.append((header, formatter)) + + rows: list[list[str]] = [[header for header, _ in active_columns]] + for _, turbine in turbine_df.iterrows(): + rows.append([formatter(turbine) for _, formatter in active_columns]) + return rows + + def build_lightning_event_table_data( centroid_lat: float, centroid_lng: float, lightning_df: pd.DataFrame ) -> tuple[list[list[str]], list[str]]: diff --git a/src/visualization/basemap.py b/src/visualization/basemap.py new file mode 100644 index 0000000..d988a8a --- /dev/null +++ b/src/visualization/basemap.py @@ -0,0 +1,149 @@ +"""Satellite basemap helper for coordinate-plane charts. + +Fetches a Mapbox static satellite image for a longitude/latitude bounding box and +adds it behind the existing Plotly scatter layers so turbines, lightning, rings and +storm polygons render on top of real imagery. Fails gracefully (no-op) when the +token is missing or the request fails, keeping report generation robust. +""" +import base64 +import logging +import math +import os +from typing import Dict, Optional, Tuple + +import plotly.graph_objects as go + +logger = logging.getLogger(__name__) + +MAPBOX_STATIC_URL = "https://api.mapbox.com/styles/v1/mapbox/satellite-v9/static" +MAPBOX_MAX_DIMENSION = 1280 +DEFAULT_IMAGE_HEIGHT = 1000 +REQUEST_TIMEOUT_SECONDS = 15 + +_image_cache: Dict[Tuple[float, float, float, float, int, int], str] = {} + + +def _lonlat_to_mercator(lon: float, lat: float) -> Tuple[float, float]: + """Project a longitude/latitude pair to Web Mercator units.""" + clamped_lat = max(min(lat, 85.05112878), -85.05112878) + x = math.radians(lon) + y = math.log(math.tan(math.pi / 4 + math.radians(clamped_lat) / 2)) + return x, y + + +def _compute_dimensions(lon_min: float, lon_max: float, lat_min: float, lat_max: float) -> Tuple[int, int]: + """Return Mapbox image width/height matching the bbox aspect ratio in Mercator.""" + mx_min, my_min = _lonlat_to_mercator(lon_min, lat_min) + mx_max, my_max = _lonlat_to_mercator(lon_max, lat_max) + + mercator_width = abs(mx_max - mx_min) + mercator_height = abs(my_max - my_min) + if mercator_height <= 0: + return MAPBOX_MAX_DIMENSION, MAPBOX_MAX_DIMENSION + + aspect = mercator_width / mercator_height + + if aspect >= 1: + width = MAPBOX_MAX_DIMENSION + height = max(1, round(width / aspect)) + else: + height = DEFAULT_IMAGE_HEIGHT + width = max(1, round(height * aspect)) + + width = min(width, MAPBOX_MAX_DIMENSION) + height = min(height, MAPBOX_MAX_DIMENSION) + return width, height + + +def _fetch_satellite_image( + token: str, + lon_min: float, + lon_max: float, + lat_min: float, + lat_max: float, + width: int, + height: int, +) -> Optional[str]: + """Fetch the Mapbox static satellite image and return it as a base64 data URI.""" + import requests + + bbox = f"[{lon_min},{lat_min},{lon_max},{lat_max}]" + url = f"{MAPBOX_STATIC_URL}/{bbox}/{width}x{height}@2x" + params = { + "access_token": token, + "attribution": "false", + "logo": "false", + } + + response = requests.get(url, params=params, timeout=REQUEST_TIMEOUT_SECONDS) + if response.status_code != 200: + logger.warning( + "Mapbox static image request failed (status %s): %s", + response.status_code, + response.text[:200], + ) + return None + + encoded = base64.b64encode(response.content).decode("ascii") + return f"data:image/png;base64,{encoded}" + + +def add_satellite_basemap( + fig: go.Figure, + lon_min: float, + lon_max: float, + lat_min: float, + lat_max: float, +) -> go.Figure: + """Add a satellite basemap behind the figure traces for the given bbox. + + Reuses cached imagery for an identical bbox so the IC, CG and storm charts of a + single farm share one Mapbox request. Returns the figure unchanged when no token + is configured or the request fails. + """ + token = os.getenv("MAPBOX_TOKEN") + if not token: + logger.warning("MAPBOX_TOKEN is not set; rendering charts without satellite basemap") + return fig + + width, height = _compute_dimensions(lon_min, lon_max, lat_min, lat_max) + cache_key = ( + round(lon_min, 6), + round(lon_max, 6), + round(lat_min, 6), + round(lat_max, 6), + width, + height, + ) + + image_source = _image_cache.get(cache_key) + if image_source is None: + try: + image_source = _fetch_satellite_image( + token, lon_min, lon_max, lat_min, lat_max, width, height + ) + except Exception as exc: + logger.warning("Failed to fetch Mapbox satellite basemap: %s", exc) + return fig + + if image_source is None: + return fig + + _image_cache[cache_key] = image_source + + fig.add_layout_image( + dict( + source=image_source, + xref="x", + yref="y", + x=lon_min, + y=lat_max, + sizex=lon_max - lon_min, + sizey=lat_max - lat_min, + xanchor="left", + yanchor="top", + sizing="stretch", + layer="below", + ) + ) + return fig diff --git a/src/visualization/maps.py b/src/visualization/maps.py index 243547b..62d09e5 100644 --- a/src/visualization/maps.py +++ b/src/visualization/maps.py @@ -3,8 +3,14 @@ import plotly.express as px import numpy as np from src.analysis.geospatial import create_circle_points, haversine_distance from src.config import config +from src.visualization.basemap import add_satellite_basemap import pandas as pd +COORDINATE_PLANE_RING_LINE_WIDTH = 4 +COORDINATE_PLANE_LIGHTNING_SIZE_MIN = 10 +COORDINATE_PLANE_LIGHTNING_SIZE_MAX = 24 +COORDINATE_PLANE_LIGHTNING_CURRENT_SCALE = 800 + def plot_turbine_map(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame) -> go.Figure: turbine_lat = turbine_row['lat'] turbine_lon = turbine_row['lng'] @@ -487,7 +493,7 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da x=circle_lons, # X-axis = Longitude y=circle_lats, # Y-axis = Latitude mode='lines', - line=dict(color=color, width=2), + line=dict(color=color, width=COORDINATE_PLANE_RING_LINE_WIDTH), opacity=0.6, name=f'{radius/1000:.1f}km Distance Ring', showlegend=True @@ -567,7 +573,11 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da break lightning_colors.append(color) - lightning_sizes = np.clip(ic_lightning_df['current_abs'] / 1500, 3, 12) + lightning_sizes = np.clip( + ic_lightning_df['current_abs'] / COORDINATE_PLANE_LIGHTNING_CURRENT_SCALE, + COORDINATE_PLANE_LIGHTNING_SIZE_MIN, + COORDINATE_PLANE_LIGHTNING_SIZE_MAX, + ) fig.add_trace(go.Scatter( x=ic_lightning_df['lng'], # X-axis = Longitude @@ -576,9 +586,10 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da marker=dict( size=lightning_sizes, color=lightning_colors, - opacity=0.8, + opacity=0.9, symbol='circle', - sizemin=3 + sizemin=COORDINATE_PLANE_LIGHTNING_SIZE_MIN, + line=dict(width=1, color='white'), ), name='Intercloud Lightning', showlegend=True @@ -592,13 +603,15 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da lon_min = turbine_lon - max_radius_deg * 1.5 lon_max = turbine_lon + max_radius_deg * 1.5 + add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max) + fig.update_layout( title=f'Intercloud Lightning - Coordinate Plane View - Central Turbine: {turbine_row["name"]}', xaxis=dict( title=dict(text='Longitude', font=dict(size=28)), # x-axis title font size tickfont=dict(size=22), # x-axis tick label font size range=[lon_min, lon_max], - showgrid=True, + showgrid=False, gridwidth=1, gridcolor='lightgray', zeroline=False @@ -607,7 +620,7 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da title=dict(text='Latitude', font=dict(size=28)), # y-axis title font size tickfont=dict(size=22), # y-axis tick label font size range=[lat_min, lat_max], - showgrid=True, + showgrid=False, gridwidth=1, gridcolor='lightgray', zeroline=False @@ -652,7 +665,7 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df: x=circle_lons, # X-axis = Longitude y=circle_lats, # Y-axis = Latitude mode='lines', - line=dict(color=color, width=2), + line=dict(color=color, width=COORDINATE_PLANE_RING_LINE_WIDTH), opacity=0.6, name=f'{radius/1000:.1f}km Distance Ring', showlegend=True @@ -732,7 +745,11 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df: break lightning_colors.append(color) - lightning_sizes = np.clip(cg_lightning_df['current_abs'] / 1500, 3, 12) + lightning_sizes = np.clip( + cg_lightning_df['current_abs'] / COORDINATE_PLANE_LIGHTNING_CURRENT_SCALE, + COORDINATE_PLANE_LIGHTNING_SIZE_MIN, + COORDINATE_PLANE_LIGHTNING_SIZE_MAX, + ) fig.add_trace(go.Scatter( x=cg_lightning_df['lng'], # X-axis = Longitude @@ -741,9 +758,10 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df: marker=dict( size=lightning_sizes, color=lightning_colors, - opacity=0.8, + opacity=0.9, symbol='circle', - sizemin=3 + sizemin=COORDINATE_PLANE_LIGHTNING_SIZE_MIN, + line=dict(width=1, color='white'), ), name='Cloud-to-Ground Lightning', showlegend=True @@ -757,13 +775,15 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df: lon_min = turbine_lon - max_radius_deg * 1.5 lon_max = turbine_lon + max_radius_deg * 1.5 + add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max) + fig.update_layout( title=f'Cloud-to-Ground Lightning - Coordinate Plane View - Central Turbine: {turbine_row["name"]}', xaxis=dict( title=dict(text='Longitude', font=dict(size=28)), # x-axis title font size tickfont=dict(size=22), # x-axis tick label font size range=[lon_min, lon_max], - showgrid=True, + showgrid=False, gridwidth=1, gridcolor='lightgray', zeroline=False @@ -772,7 +792,7 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df: title=dict(text='Latitude', font=dict(size=28)), # y-axis title font size tickfont=dict(size=22), # y-axis tick label font size range=[lat_min, lat_max], - showgrid=True, + showgrid=False, gridwidth=1, gridcolor='lightgray', zeroline=False diff --git a/src/visualization/storm_cells.py b/src/visualization/storm_cells.py index 88da488..a9c1afc 100644 --- a/src/visualization/storm_cells.py +++ b/src/visualization/storm_cells.py @@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo from src.analysis.geospatial import haversine_distance from src.config import config from src.utils import parse_period_string_to_datetime +from src.visualization.basemap import add_satellite_basemap def format_datetime_for_display(datetime_str: str) -> str: """ @@ -165,7 +166,7 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D x=circle_lons, # X-axis = Longitude y=circle_lats, # Y-axis = Latitude mode='lines', - line=dict(color=color, width=2), + line=dict(color=color, width=4), opacity=0.6, name=f'{radius/1000:.0f}km Ring', showlegend=True @@ -284,6 +285,8 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D lon_min = center_lon - max_radius_deg * 1.5 lon_max = center_lon + max_radius_deg * 1.5 + add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max) + fig.update_layout( font=dict(size=18), title=dict(text='Storm Cells - Coordinate Plane View', font=dict(size=28)), @@ -291,7 +294,7 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D yaxis_title='Latitude', xaxis=dict( range=[lon_min, lon_max], - showgrid=True, + showgrid=False, gridwidth=1, gridcolor='lightgray', zeroline=False, @@ -300,7 +303,7 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D ), yaxis=dict( range=[lat_min, lat_max], - showgrid=True, + showgrid=False, gridwidth=1, gridcolor='lightgray', zeroline=False,