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:
parent
64b99eed06
commit
17c9aa865a
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]]:
|
||||
|
||||
149
src/visualization/basemap.py
Normal file
149
src/visualization/basemap.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user