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.

This commit is contained in:
erdemerikci 2026-06-03 16:16:27 +03:00
parent 64b99eed06
commit 17c9aa865a
6 changed files with 243 additions and 30 deletions

View File

@ -28,6 +28,9 @@ services:
# service to call Gemini itself. # service to call Gemini itself.
- GEMINI_API_KEY=${GEMINI_API_KEY:-} - GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-1.5-flash} - 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: ports:
- "8000:8000" - "8000:8000"
networks: networks:

View File

@ -24,6 +24,7 @@ from src.reporting.docx_sections import (
_build_current_vs_distance_chart, _build_current_vs_distance_chart,
build_lightning_event_table_data, build_lightning_event_table_data,
build_risk_table_data, build_risk_table_data,
build_turbine_information_table_data,
) )
from src.utils import ( from src.utils import (
format_datetime_ddmmyyyy_hhmm, format_datetime_ddmmyyyy_hhmm,
@ -514,21 +515,7 @@ def create_docx_report(
# Turbine information # Turbine information
_add_title(doc, "Turbine Information", size_pt=16, align=WD_ALIGN_PARAGRAPH.LEFT) _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) _add_paragraph(doc, "This table contains detailed information about all turbines in the wind farm.", size_pt=10)
turb_rows: list[list[str]] = [[ _add_table(doc, build_turbine_information_table_data(turbine_df))
"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)
# Gemini commentary (single API call per report run; falls back deterministically if Gemini is unavailable) # 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) analysis_radius_km = float(get_analysis_radius_m()) / 1000.0 if get_analysis_radius_m() > 0 else float(max(config.distance_rings) / 1000.0)

View File

@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Callable
import numpy as np import numpy as np
import pandas as pd import pandas as pd
import plotly.graph_objects as go import plotly.graph_objects as go
@ -166,6 +168,55 @@ def _build_current_vs_distance_chart(
return fig 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( def build_lightning_event_table_data(
centroid_lat: float, centroid_lng: float, lightning_df: pd.DataFrame centroid_lat: float, centroid_lng: float, lightning_df: pd.DataFrame
) -> tuple[list[list[str]], list[str]]: ) -> tuple[list[list[str]], list[str]]:

View File

@ -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

View File

@ -3,8 +3,14 @@ import plotly.express as px
import numpy as np import numpy as np
from src.analysis.geospatial import create_circle_points, haversine_distance from src.analysis.geospatial import create_circle_points, haversine_distance
from src.config import config from src.config import config
from src.visualization.basemap import add_satellite_basemap
import pandas as pd 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: def plot_turbine_map(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame) -> go.Figure:
turbine_lat = turbine_row['lat'] turbine_lat = turbine_row['lat']
turbine_lon = turbine_row['lng'] 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 x=circle_lons, # X-axis = Longitude
y=circle_lats, # Y-axis = Latitude y=circle_lats, # Y-axis = Latitude
mode='lines', mode='lines',
line=dict(color=color, width=2), line=dict(color=color, width=COORDINATE_PLANE_RING_LINE_WIDTH),
opacity=0.6, opacity=0.6,
name=f'{radius/1000:.1f}km Distance Ring', name=f'{radius/1000:.1f}km Distance Ring',
showlegend=True showlegend=True
@ -567,7 +573,11 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da
break break
lightning_colors.append(color) 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( fig.add_trace(go.Scatter(
x=ic_lightning_df['lng'], # X-axis = Longitude 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( marker=dict(
size=lightning_sizes, size=lightning_sizes,
color=lightning_colors, color=lightning_colors,
opacity=0.8, opacity=0.9,
symbol='circle', symbol='circle',
sizemin=3 sizemin=COORDINATE_PLANE_LIGHTNING_SIZE_MIN,
line=dict(width=1, color='white'),
), ),
name='Intercloud Lightning', name='Intercloud Lightning',
showlegend=True 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_min = turbine_lon - max_radius_deg * 1.5
lon_max = 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( fig.update_layout(
title=f'Intercloud Lightning - Coordinate Plane View - Central Turbine: {turbine_row["name"]}', title=f'Intercloud Lightning - Coordinate Plane View - Central Turbine: {turbine_row["name"]}',
xaxis=dict( xaxis=dict(
title=dict(text='Longitude', font=dict(size=28)), # x-axis title font size title=dict(text='Longitude', font=dict(size=28)), # x-axis title font size
tickfont=dict(size=22), # x-axis tick label font size tickfont=dict(size=22), # x-axis tick label font size
range=[lon_min, lon_max], range=[lon_min, lon_max],
showgrid=True, showgrid=False,
gridwidth=1, gridwidth=1,
gridcolor='lightgray', gridcolor='lightgray',
zeroline=False 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 title=dict(text='Latitude', font=dict(size=28)), # y-axis title font size
tickfont=dict(size=22), # y-axis tick label font size tickfont=dict(size=22), # y-axis tick label font size
range=[lat_min, lat_max], range=[lat_min, lat_max],
showgrid=True, showgrid=False,
gridwidth=1, gridwidth=1,
gridcolor='lightgray', gridcolor='lightgray',
zeroline=False 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 x=circle_lons, # X-axis = Longitude
y=circle_lats, # Y-axis = Latitude y=circle_lats, # Y-axis = Latitude
mode='lines', mode='lines',
line=dict(color=color, width=2), line=dict(color=color, width=COORDINATE_PLANE_RING_LINE_WIDTH),
opacity=0.6, opacity=0.6,
name=f'{radius/1000:.1f}km Distance Ring', name=f'{radius/1000:.1f}km Distance Ring',
showlegend=True showlegend=True
@ -732,7 +745,11 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df:
break break
lightning_colors.append(color) 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( fig.add_trace(go.Scatter(
x=cg_lightning_df['lng'], # X-axis = Longitude 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( marker=dict(
size=lightning_sizes, size=lightning_sizes,
color=lightning_colors, color=lightning_colors,
opacity=0.8, opacity=0.9,
symbol='circle', symbol='circle',
sizemin=3 sizemin=COORDINATE_PLANE_LIGHTNING_SIZE_MIN,
line=dict(width=1, color='white'),
), ),
name='Cloud-to-Ground Lightning', name='Cloud-to-Ground Lightning',
showlegend=True 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_min = turbine_lon - max_radius_deg * 1.5
lon_max = 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( fig.update_layout(
title=f'Cloud-to-Ground Lightning - Coordinate Plane View - Central Turbine: {turbine_row["name"]}', title=f'Cloud-to-Ground Lightning - Coordinate Plane View - Central Turbine: {turbine_row["name"]}',
xaxis=dict( xaxis=dict(
title=dict(text='Longitude', font=dict(size=28)), # x-axis title font size title=dict(text='Longitude', font=dict(size=28)), # x-axis title font size
tickfont=dict(size=22), # x-axis tick label font size tickfont=dict(size=22), # x-axis tick label font size
range=[lon_min, lon_max], range=[lon_min, lon_max],
showgrid=True, showgrid=False,
gridwidth=1, gridwidth=1,
gridcolor='lightgray', gridcolor='lightgray',
zeroline=False 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 title=dict(text='Latitude', font=dict(size=28)), # y-axis title font size
tickfont=dict(size=22), # y-axis tick label font size tickfont=dict(size=22), # y-axis tick label font size
range=[lat_min, lat_max], range=[lat_min, lat_max],
showgrid=True, showgrid=False,
gridwidth=1, gridwidth=1,
gridcolor='lightgray', gridcolor='lightgray',
zeroline=False zeroline=False

View File

@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo
from src.analysis.geospatial import haversine_distance from src.analysis.geospatial import haversine_distance
from src.config import config from src.config import config
from src.utils import parse_period_string_to_datetime 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: 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 x=circle_lons, # X-axis = Longitude
y=circle_lats, # Y-axis = Latitude y=circle_lats, # Y-axis = Latitude
mode='lines', mode='lines',
line=dict(color=color, width=2), line=dict(color=color, width=4),
opacity=0.6, opacity=0.6,
name=f'{radius/1000:.0f}km Ring', name=f'{radius/1000:.0f}km Ring',
showlegend=True 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_min = center_lon - max_radius_deg * 1.5
lon_max = 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( fig.update_layout(
font=dict(size=18), font=dict(size=18),
title=dict(text='Storm Cells - Coordinate Plane View', font=dict(size=28)), 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', yaxis_title='Latitude',
xaxis=dict( xaxis=dict(
range=[lon_min, lon_max], range=[lon_min, lon_max],
showgrid=True, showgrid=False,
gridwidth=1, gridwidth=1,
gridcolor='lightgray', gridcolor='lightgray',
zeroline=False, zeroline=False,
@ -300,7 +303,7 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D
), ),
yaxis=dict( yaxis=dict(
range=[lat_min, lat_max], range=[lat_min, lat_max],
showgrid=True, showgrid=False,
gridwidth=1, gridwidth=1,
gridcolor='lightgray', gridcolor='lightgray',
zeroline=False, zeroline=False,