Enhance report generation by adding turbine reference map functionality. Introduce new functions for calculating content dimensions and rendering turbine reference images. Update report layout to include turbine reference maps when applicable, improving visualization of turbine data. Refactor related string resources for better localization and clarity.

This commit is contained in:
erdemerikci 2026-06-19 13:25:55 +03:00
parent 8152e76d05
commit 3dbd94a044
4 changed files with 560 additions and 194 deletions

View File

@ -38,7 +38,15 @@ from src.utils import (
get_analysis_radius_m,
)
from src.analysis.geospatial import haversine_distance, haversine_distance_vectorized
from src.visualization.maps import plot_cloud_to_ground_coordinate_plane, plot_intercloud_coordinate_plane
from src.visualization.maps import (
plot_cloud_to_ground_coordinate_plane,
plot_intercloud_coordinate_plane,
plot_turbine_reference_map,
figure_layout_aspect,
render_coordinate_plane_png,
render_turbine_reference_png,
turbines_need_reference_map,
)
from src.visualization.storm_cells import (
create_storm_cells_map,
create_storm_cells_summary,
@ -83,6 +91,20 @@ def _content_width_inches(doc: Document) -> float:
return section.page_width.inches - section.left_margin.inches - section.right_margin.inches
def _content_height_inches(doc: Document) -> float:
section = doc.sections[0]
return section.page_height.inches - section.top_margin.inches - section.bottom_margin.inches
def _main_map_height_inches(
content_width: float,
content_height: float,
fig,
) -> float:
aspect = figure_layout_aspect(fig)
return min(content_width * aspect, max(content_height - 0.75, content_width * 0.5))
def _add_header_logo(doc: Document) -> None:
logo_path = _resolve_logo_path("iklim.png")
if not os.path.isfile(logo_path):
@ -379,8 +401,42 @@ def _add_risk_color_legend(doc: Document, size_pt: int = 10, lang: str | None =
r.font.size = Pt(size_pt)
def _add_image_from_bytes(doc: Document, png_bytes: bytes, width_inches: float) -> None:
def _add_turbine_reference_map_if_needed(
doc: Document,
turbine_df: pd.DataFrame,
content_width: float,
s,
lang,
height_inches: float | None = None,
) -> None:
if not turbines_need_reference_map(turbine_df):
return
ref_fig = plot_turbine_reference_map(turbine_df, lang=lang)
if ref_fig is None:
return
doc.add_paragraph("")
_add_paragraph(doc, s.map_turbine_reference_title, size_pt=11, bold=True)
_add_paragraph(doc, s.map_turbine_reference_desc, size_pt=10)
if height_inches is None:
height_inches = content_width * figure_layout_aspect(ref_fig)
_add_image_from_bytes(
doc,
render_turbine_reference_png(ref_fig),
content_width,
height_inches,
)
def _add_image_from_bytes(
doc: Document,
png_bytes: bytes,
width_inches: float,
height_inches: float | None = None,
) -> None:
stream = io.BytesIO(png_bytes)
if height_inches is not None:
doc.add_picture(stream, width=Inches(width_inches), height=Inches(height_inches))
return
doc.add_picture(stream, width=Inches(width_inches))
@ -695,6 +751,7 @@ def create_docx_report(
# Turbine information
_add_title(doc, s.turbine_information, size_pt=16, align=WD_ALIGN_PARAGRAPH.LEFT)
_add_turbine_reference_map_if_needed(doc, turbine_df, content_width, s, lang)
_add_paragraph(doc, s.turbine_information_desc, size_pt=10)
_add_table(doc, build_turbine_information_table_data(turbine_df, lang))
@ -905,7 +962,8 @@ def create_docx_report(
if start_date and end_date:
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10)
cg_fig = plot_cloud_to_ground_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre, lang=lang)
_add_image_from_bytes(doc, cg_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width)
map_height = _main_map_height_inches(content_width, _content_height_inches(doc), cg_fig)
_add_image_from_bytes(doc, render_coordinate_plane_png(cg_fig), content_width, map_height)
doc.add_page_break()
_add_title(doc, s.cloud_to_ground_current_vs_distance, size_pt=14)
@ -943,7 +1001,8 @@ def create_docx_report(
if start_date and end_date:
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10)
ic_fig = plot_intercloud_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre, lang=lang)
_add_image_from_bytes(doc, ic_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width)
map_height = _main_map_height_inches(content_width, _content_height_inches(doc), ic_fig)
_add_image_from_bytes(doc, render_coordinate_plane_png(ic_fig), content_width, map_height)
doc.add_page_break()
_add_title(doc, s.intercloud_current_vs_distance, size_pt=14)
@ -1166,10 +1225,12 @@ def create_docx_report(
_add_title(doc, s.storm_cells, size_pt=14)
_add_paragraph(doc, s.storm_cells_daily_viz, size_pt=10)
storm_fig = create_storm_cells_map(storm_data, turbine_df, lang=lang)
map_height = _main_map_height_inches(content_width, _content_height_inches(doc), storm_fig)
_add_image_from_bytes(
doc,
storm_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"),
render_coordinate_plane_png(storm_fig),
content_width,
map_height,
)
doc.add_page_break()

View File

@ -216,8 +216,13 @@ class ReportStrings:
map_storm_cells: str
map_cg_lightning: str
map_ic_lightning: str
map_lightning_ring_range: str
map_lightning_ring_beyond: str
map_distance_ring: str
map_severity_legend: str
map_turbine_reference_title: str
map_turbine_reference_desc: str
map_turbine_reference_plane_title: str
storm_severity_names: dict[str, str]
hist_minutes_from_start: str
hist_lightning_count: str
@ -439,8 +444,13 @@ _STRINGS_EN = ReportStrings(
map_storm_cells="Storm Cells",
map_cg_lightning="Cloud-to-Ground Lightning",
map_ic_lightning="Intercloud Lightning",
map_lightning_ring_range="{name} ({lower:.0f}-{upper:.0f} km)",
map_lightning_ring_beyond="{name} (>{outer:.0f} km)",
map_distance_ring="{radius:.1f}km Distance Ring",
map_severity_legend="{severity} Severity",
map_turbine_reference_title="Turbine Reference Map",
map_turbine_reference_desc="Zoomed view of turbine names and locations.",
map_turbine_reference_plane_title="Turbine Layout",
storm_severity_names={"high": "High", "medium": "Medium", "low": "Low", "unknown": "Unknown"},
hist_minutes_from_start="Minutes from start",
hist_lightning_count="Lightning Count",
@ -671,8 +681,13 @@ _STRINGS_TR = ReportStrings(
map_storm_cells="Fırtına Hücreleri",
map_cg_lightning="Yıldırım",
map_ic_lightning="Şimşek",
map_lightning_ring_range="{name} ({lower:.0f}-{upper:.0f} km)",
map_lightning_ring_beyond="{name} (>{outer:.0f} km)",
map_distance_ring="{radius:.1f} km Mesafe Halkası",
map_severity_legend="{severity} Şiddet",
map_turbine_reference_title="Türbin Referans Haritası",
map_turbine_reference_desc="Türbin adları ve konumlarının yakınlaştırılmış görünümü.",
map_turbine_reference_plane_title="Türbin Yerleşimi",
storm_severity_names={"high": "Yüksek", "medium": "Orta", "low": "Düşük", "unknown": "Bilinmeyen"},
hist_minutes_from_start="Başlangıçtan itibaren geçen dakika",
hist_lightning_count="Yıldırım Sayısı",

View File

@ -13,6 +13,387 @@ COORDINATE_PLANE_LIGHTNING_SIZE_MAX = 24
COORDINATE_PLANE_LIGHTNING_CURRENT_SCALE = 800
COORDINATE_PLANE_TURBINE_NAME_FONT_SIZE = 9
COORDINATE_PLANE_TURBINE_TEXT_POSITION = 'middle center'
TURBINE_REFERENCE_NAME_FONT_SIZE = 11
TURBINE_REFERENCE_MARKER_SIZE = 22
TURBINE_REFERENCE_MIN_TURBINES = 4
TURBINE_REFERENCE_MIN_SEPARATION_M = 800.0
COORDINATE_PLANE_FIGURE_WIDTH = 1400
COORDINATE_PLANE_EXPORT_SCALE = 2
COORDINATE_PLANE_BOUNDS_PADDING = 1.06
COORDINATE_PLANE_MARGIN_L = 20
COORDINATE_PLANE_MARGIN_R = 20
COORDINATE_PLANE_MARGIN_T = 50
COORDINATE_PLANE_MARGIN_B = 88
def figure_layout_aspect(fig: go.Figure) -> float:
width = float(fig.layout.width or COORDINATE_PLANE_FIGURE_WIDTH)
height = float(fig.layout.height or width)
return height / width
def coordinate_plane_figure_aspect() -> float:
return 1.05
def turbine_reference_figure_aspect() -> float:
return 0.78
def render_coordinate_plane_png(fig: go.Figure) -> bytes:
width = int(fig.layout.width or COORDINATE_PLANE_FIGURE_WIDTH)
height = int(fig.layout.height or width)
return fig.to_image(
format='png',
width=width,
height=height,
scale=COORDINATE_PLANE_EXPORT_SCALE,
engine='kaleido',
)
def render_turbine_reference_png(fig: go.Figure) -> bytes:
width = int(fig.layout.width or COORDINATE_PLANE_FIGURE_WIDTH)
height = int(fig.layout.height or width)
return fig.to_image(
format='png',
width=width,
height=height,
scale=COORDINATE_PLANE_EXPORT_SCALE,
engine='kaleido',
)
def _geographic_yaxis_scaleratio(center_lat: float) -> float:
cos_lat = float(np.cos(np.radians(center_lat)))
if cos_lat < 1e-6:
cos_lat = 1e-6
return 1.0 / cos_lat
def _coordinate_plane_bounds(
center_lat: float,
center_lon: float,
max_radius_m: float,
padding_factor: float = COORDINATE_PLANE_BOUNDS_PADDING,
) -> tuple[float, float, float, float]:
cos_lat = max(float(np.cos(np.radians(center_lat))), 1e-6)
lat_pad = (max_radius_m / 111_000.0) * padding_factor
lon_pad = (max_radius_m / (111_000.0 * cos_lat)) * padding_factor
return (
center_lat - lat_pad,
center_lat + lat_pad,
center_lon - lon_pad,
center_lon + lon_pad,
)
def _compute_figure_dimensions(
center_lat: float,
lon_min: float,
lon_max: float,
lat_min: float,
lat_max: float,
figure_width: int = COORDINATE_PLANE_FIGURE_WIDTH,
) -> tuple[int, int]:
lon_span = max(lon_max - lon_min, 1e-9)
lat_span = max(lat_max - lat_min, 1e-9)
scaleratio = _geographic_yaxis_scaleratio(center_lat)
plot_aspect = (lat_span * scaleratio) / lon_span
plot_width = figure_width - COORDINATE_PLANE_MARGIN_L - COORDINATE_PLANE_MARGIN_R
plot_height = max(int(round(plot_width * plot_aspect)), 1)
figure_height = plot_height + COORDINATE_PLANE_MARGIN_T + COORDINATE_PLANE_MARGIN_B
return figure_width, figure_height
def _apply_coordinate_plane_layout(
fig: go.Figure,
title: str,
s,
center_lat: float,
lon_min: float,
lon_max: float,
lat_min: float,
lat_max: float,
) -> None:
figure_width, figure_height = _compute_figure_dimensions(
center_lat,
lon_min,
lon_max,
lat_min,
lat_max,
)
fig.update_layout(
title=dict(text=title, font=dict(size=28)),
xaxis=dict(
title=dict(text=s.map_longitude, font=dict(size=28)),
tickfont=dict(size=22),
range=[lon_min, lon_max],
showgrid=False,
zeroline=False,
),
yaxis=dict(
title=dict(text=s.map_latitude, font=dict(size=28)),
tickfont=dict(size=22),
range=[lat_min, lat_max],
scaleanchor='x',
scaleratio=_geographic_yaxis_scaleratio(center_lat),
showgrid=False,
zeroline=False,
),
plot_bgcolor='white',
paper_bgcolor='white',
showlegend=True,
legend=dict(
title=dict(text=s.map_legend, font=dict(size=22)),
font=dict(size=18),
orientation='h',
x=0.5,
xanchor='center',
y=-0.08,
yanchor='top',
bgcolor='rgba(255, 255, 255, 0.8)',
bordercolor='black',
borderwidth=1,
itemsizing='constant',
itemwidth=30,
),
width=figure_width,
height=figure_height,
margin=dict(
l=COORDINATE_PLANE_MARGIN_L,
r=COORDINATE_PLANE_MARGIN_R,
t=COORDINATE_PLANE_MARGIN_T,
b=COORDINATE_PLANE_MARGIN_B,
),
font=dict(size=18),
)
def _lightning_ring_indices_for_strikes(
turbine_lat: float,
turbine_lon: float,
strike_df: pd.DataFrame,
full_lightning_df: pd.DataFrame,
precomputed: dict | None,
type_mask: np.ndarray,
) -> np.ndarray:
if len(strike_df) == 0:
return np.array([], dtype=int)
if (
precomputed is not None
and 'ring_idx' in precomputed
and len(precomputed['ring_idx']) == len(full_lightning_df)
):
return precomputed['ring_idx'][type_mask].astype(int)
indices: list[int] = []
for _, lightning in strike_df.iterrows():
d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng'])
ring_i = len(config.distance_rings) - 1
for i, ring in enumerate(config.distance_rings):
if d <= ring:
ring_i = i
break
indices.append(ring_i)
return np.array(indices, dtype=int)
def _lightning_ring_legend_label(base_legend_name: str, ring_i: int, s) -> str:
lower_km = 0.0 if ring_i == 0 else config.distance_rings[ring_i - 1] / 1000.0
upper_km = config.distance_rings[ring_i] / 1000.0
return s.map_lightning_ring_range.format(
name=base_legend_name,
lower=lower_km,
upper=upper_km,
)
def _lightning_ring_beyond_legend_label(base_legend_name: str, s) -> str:
outer_km = config.distance_rings[-1] / 1000.0
return s.map_lightning_ring_beyond.format(name=base_legend_name, outer=outer_km)
def _add_ring_colored_lightning_traces(
fig: go.Figure,
strike_df: pd.DataFrame,
ring_indices: np.ndarray,
sizes: np.ndarray,
base_legend_name: str,
s,
) -> None:
if len(strike_df) == 0:
return
lngs = strike_df['lng'].values
lats = strike_df['lat'].values
n_rings = len(config.distance_rings)
for ring_i in range(n_rings):
mask = ring_indices == ring_i
if not np.any(mask):
continue
color = config.ring_colors[ring_i]
fig.add_trace(go.Scatter(
x=lngs[mask],
y=lats[mask],
mode='markers',
marker=dict(
size=sizes[mask],
color=color,
opacity=0.9,
symbol='circle',
sizemin=COORDINATE_PLANE_LIGHTNING_SIZE_MIN,
line=dict(width=1, color='white'),
),
name=_lightning_ring_legend_label(base_legend_name, ring_i, s),
showlegend=True,
))
outside = ring_indices >= n_rings
if np.any(outside):
fig.add_trace(go.Scatter(
x=lngs[outside],
y=lats[outside],
mode='markers',
marker=dict(
size=sizes[outside],
color='gray',
opacity=0.9,
symbol='circle',
sizemin=COORDINATE_PLANE_LIGHTNING_SIZE_MIN,
line=dict(width=1, color='white'),
),
name=_lightning_ring_beyond_legend_label(base_legend_name, s),
showlegend=True,
))
def _turbine_bounds_with_padding(
turbine_df: pd.DataFrame,
padding_ratio: float = 0.18,
min_pad_deg: float = 0.0015,
) -> tuple[float, float, float, float]:
lat_min = float(turbine_df['lat'].min())
lat_max = float(turbine_df['lat'].max())
lon_min = float(turbine_df['lng'].min())
lon_max = float(turbine_df['lng'].max())
center_lat = (lat_min + lat_max) / 2.0
cos_lat = max(float(np.cos(np.radians(center_lat))), 1e-6)
lat_pad = max((lat_max - lat_min) * padding_ratio, min_pad_deg)
lon_pad = max((lon_max - lon_min) * padding_ratio, min_pad_deg / cos_lat)
return lat_min - lat_pad, lat_max + lat_pad, lon_min - lon_pad, lon_max + lon_pad
def turbines_need_reference_map(turbine_df: pd.DataFrame) -> bool:
if turbine_df is None or len(turbine_df) < TURBINE_REFERENCE_MIN_TURBINES:
return False
lats = turbine_df['lat'].to_numpy(dtype=float)
lngs = turbine_df['lng'].to_numpy(dtype=float)
min_dist_m = float('inf')
for i in range(len(lats)):
for j in range(i + 1, len(lats)):
min_dist_m = min(
min_dist_m,
haversine_distance(lats[i], lngs[i], lats[j], lngs[j]),
)
if min_dist_m <= TURBINE_REFERENCE_MIN_SEPARATION_M:
return True
span_deg = max(float(lats.max() - lats.min()), float(lngs.max() - lngs.min()), 1e-9)
return len(turbine_df) >= 8 and span_deg < 0.08
def plot_turbine_reference_map(
turbine_df: pd.DataFrame,
lang: ReportLanguage | None = None,
) -> go.Figure | None:
if turbine_df is None or turbine_df.empty:
return None
s = get_strings(lang or get_report_language())
lat_min, lat_max, lon_min, lon_max = _turbine_bounds_with_padding(turbine_df)
center_lat = (lat_min + lat_max) / 2.0
if 'risk_log' in turbine_df.columns:
from src.utils import get_turbine_colors_by_fixed_intervals
turbine_colors = get_turbine_colors_by_fixed_intervals(turbine_df['risk_log'].tolist())
else:
turbine_colors = ['red'] * len(turbine_df)
fig = go.Figure()
fig.add_trace(go.Scatter(
x=turbine_df['lng'],
y=turbine_df['lat'],
mode='markers+text',
marker=dict(
size=TURBINE_REFERENCE_MARKER_SIZE,
color=turbine_colors,
symbol='triangle-down',
opacity=1,
line=dict(color='black', width=1),
),
text=turbine_df['name'].tolist(),
textfont=dict(size=TURBINE_REFERENCE_NAME_FONT_SIZE, color='black'),
textposition=COORDINATE_PLANE_TURBINE_TEXT_POSITION,
name=s.map_wind_turbines,
showlegend=True,
))
add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max)
figure_width, figure_height = _compute_figure_dimensions(
center_lat,
lon_min,
lon_max,
lat_min,
lat_max,
)
fig.update_layout(
title=dict(text=s.map_turbine_reference_plane_title, font=dict(size=24)),
xaxis=dict(
title=dict(text=s.map_longitude, font=dict(size=22)),
tickfont=dict(size=18),
range=[lon_min, lon_max],
showgrid=False,
zeroline=False,
),
yaxis=dict(
title=dict(text=s.map_latitude, font=dict(size=22)),
tickfont=dict(size=18),
range=[lat_min, lat_max],
scaleanchor='x',
scaleratio=_geographic_yaxis_scaleratio(center_lat),
showgrid=False,
zeroline=False,
),
plot_bgcolor='white',
paper_bgcolor='white',
showlegend=True,
legend=dict(
title=dict(text=s.map_legend, font=dict(size=20)),
font=dict(size=16),
orientation='h',
x=0.5,
xanchor='center',
y=-0.08,
yanchor='top',
bgcolor='rgba(255, 255, 255, 0.8)',
bordercolor='black',
borderwidth=1,
),
width=figure_width,
height=figure_height,
margin=dict(
l=COORDINATE_PLANE_MARGIN_L,
r=COORDINATE_PLANE_MARGIN_R,
t=COORDINATE_PLANE_MARGIN_T,
b=COORDINATE_PLANE_MARGIN_B,
),
font=dict(size=16),
)
return fig
def plot_turbine_map(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame) -> go.Figure:
turbine_lat = turbine_row['lat']
@ -500,7 +881,7 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da
line=dict(color=color, width=COORDINATE_PLANE_RING_LINE_WIDTH),
opacity=0.6,
name=s.map_distance_ring.format(radius=radius / 1000),
showlegend=True
showlegend=False
))
# Add turbines (middle layer)
@ -557,99 +938,45 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da
ic_lightning_df = lightning_df[ic_mask]
if len(ic_lightning_df) > 0:
# Plot intercloud lightnings (foreground layer - always on top)
lightning_colors = []
if precomputed is not None and 'ring_idx' in precomputed and len(precomputed['ring_idx']) == len(lightning_df):
mask_ic = (lightning_df['p_type'].astype(str) != '0').values
ring_idx = precomputed['ring_idx'][mask_ic]
for ri in ring_idx:
if 0 <= int(ri) < len(config.ring_colors):
lightning_colors.append(config.ring_colors[int(ri)])
else:
lightning_colors.append('gray')
else:
for _, lightning in ic_lightning_df.iterrows():
d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng'])
color = 'gray'
for ring, ring_color in zip(config.distance_rings, config.ring_colors):
if d <= ring:
color = ring_color
break
lightning_colors.append(color)
ring_indices = _lightning_ring_indices_for_strikes(
turbine_lat,
turbine_lon,
ic_lightning_df,
lightning_df,
precomputed,
ic_mask.values if hasattr(ic_mask, 'values') else ic_mask,
)
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
y=ic_lightning_df['lat'], # Y-axis = Latitude
mode='markers',
marker=dict(
size=lightning_sizes,
color=lightning_colors,
opacity=0.9,
symbol='circle',
sizemin=COORDINATE_PLANE_LIGHTNING_SIZE_MIN,
line=dict(width=1, color='white'),
),
name=s.map_ic_lightning,
showlegend=True
))
_add_ring_colored_lightning_traces(
fig,
ic_lightning_df,
ring_indices,
lightning_sizes,
s.map_ic_lightning,
s,
)
# Calculate axis limits
max_radius_deg = max(config.distance_rings) / 111000
lat_min = turbine_lat - max_radius_deg * 1.5
lat_max = turbine_lat + max_radius_deg * 1.5
lon_min = turbine_lon - max_radius_deg * 1.5
lon_max = turbine_lon + max_radius_deg * 1.5
lat_min, lat_max, lon_min, lon_max = _coordinate_plane_bounds(
turbine_lat,
turbine_lon,
max(config.distance_rings),
)
add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max)
fig.update_layout(
title=s.map_ic_plane_title.format(name=turbine_row["name"]),
xaxis=dict(
title=dict(text=s.map_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=False,
gridwidth=1,
gridcolor='lightgray',
zeroline=False
),
yaxis=dict(
title=dict(text=s.map_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=False,
gridwidth=1,
gridcolor='lightgray',
zeroline=False
),
plot_bgcolor='white',
paper_bgcolor='white',
showlegend=True,
legend=dict(
title=dict(text=s.map_legend, font=dict(size=24)), # legend title font size
font=dict(size=20), # legend item font size
orientation='h',
x=0.5,
xanchor='center',
y=-0.22,
yanchor='top',
bgcolor='rgba(255, 255, 255, 0.8)',
bordercolor='black',
borderwidth=1,
itemsizing='constant',
itemwidth=30,
),
width=950,
height=950,
margin=dict(l=40, r=40, t=80, b=220),
font=dict(size=18), # global chart font size
_apply_coordinate_plane_layout(
fig,
s.map_ic_plane_title.format(name=turbine_row["name"]),
s,
turbine_lat,
lon_min,
lon_max,
lat_min,
lat_max,
)
return fig
@ -673,7 +1000,7 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df:
line=dict(color=color, width=COORDINATE_PLANE_RING_LINE_WIDTH),
opacity=0.6,
name=s.map_distance_ring.format(radius=radius / 1000),
showlegend=True
showlegend=False
))
# Add turbines (middle layer)
@ -730,99 +1057,45 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df:
cg_lightning_df = lightning_df[cg_mask]
if len(cg_lightning_df) > 0:
# Plot cloud-to-ground lightnings (foreground layer - always on top)
lightning_colors = []
if precomputed is not None and 'ring_idx' in precomputed and len(precomputed['ring_idx']) == len(lightning_df):
mask_cg = (lightning_df['p_type'].astype(str) == '0').values
ring_idx = precomputed['ring_idx'][mask_cg]
for ri in ring_idx:
if 0 <= int(ri) < len(config.ring_colors):
lightning_colors.append(config.ring_colors[int(ri)])
else:
lightning_colors.append('gray')
else:
for _, lightning in cg_lightning_df.iterrows():
d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng'])
color = 'gray'
for ring, ring_color in zip(config.distance_rings, config.ring_colors):
if d <= ring:
color = ring_color
break
lightning_colors.append(color)
ring_indices = _lightning_ring_indices_for_strikes(
turbine_lat,
turbine_lon,
cg_lightning_df,
lightning_df,
precomputed,
cg_mask.values if hasattr(cg_mask, 'values') else cg_mask,
)
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
y=cg_lightning_df['lat'], # Y-axis = Latitude
mode='markers',
marker=dict(
size=lightning_sizes,
color=lightning_colors,
opacity=0.9,
symbol='circle',
sizemin=COORDINATE_PLANE_LIGHTNING_SIZE_MIN,
line=dict(width=1, color='white'),
),
name=s.map_cg_lightning,
showlegend=True
))
_add_ring_colored_lightning_traces(
fig,
cg_lightning_df,
ring_indices,
lightning_sizes,
s.map_cg_lightning,
s,
)
# Calculate axis limits
max_radius_deg = max(config.distance_rings) / 111000
lat_min = turbine_lat - max_radius_deg * 1.5
lat_max = turbine_lat + max_radius_deg * 1.5
lon_min = turbine_lon - max_radius_deg * 1.5
lon_max = turbine_lon + max_radius_deg * 1.5
lat_min, lat_max, lon_min, lon_max = _coordinate_plane_bounds(
turbine_lat,
turbine_lon,
max(config.distance_rings),
)
add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max)
fig.update_layout(
title=s.map_cg_plane_title.format(name=turbine_row["name"]),
xaxis=dict(
title=dict(text=s.map_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=False,
gridwidth=1,
gridcolor='lightgray',
zeroline=False
),
yaxis=dict(
title=dict(text=s.map_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=False,
gridwidth=1,
gridcolor='lightgray',
zeroline=False
),
plot_bgcolor='white',
paper_bgcolor='white',
showlegend=True,
legend=dict(
title=dict(text=s.map_legend, font=dict(size=24)), # legend title font size
font=dict(size=20), # legend item font size
orientation='h',
x=0.5,
xanchor='center',
y=-0.22,
yanchor='top',
bgcolor='rgba(255, 255, 255, 0.8)',
bordercolor='black',
borderwidth=1,
itemsizing='constant',
itemwidth=30,
),
width=950,
height=950,
margin=dict(l=40, r=40, t=80, b=220),
font=dict(size=18), # global chart font size
_apply_coordinate_plane_layout(
fig,
s.map_cg_plane_title.format(name=turbine_row["name"]),
s,
turbine_lat,
lon_min,
lon_max,
lat_min,
lat_max,
)
return fig

View File

@ -14,8 +14,14 @@ from src.reporting.strings import ReportLanguage, get_report_language, get_strin
from src.utils import parse_period_string_to_datetime
from src.visualization.basemap import add_satellite_basemap
from src.visualization.maps import (
COORDINATE_PLANE_MARGIN_B,
COORDINATE_PLANE_MARGIN_L,
COORDINATE_PLANE_MARGIN_R,
COORDINATE_PLANE_MARGIN_T,
COORDINATE_PLANE_TURBINE_NAME_FONT_SIZE,
COORDINATE_PLANE_TURBINE_TEXT_POSITION,
_compute_figure_dimensions,
_geographic_yaxis_scaleratio,
)
def format_datetime_for_display(datetime_str: str) -> str:
@ -286,8 +292,10 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D
lat_max = max(bounds_lats)
lon_min = min(bounds_lons)
lon_max = max(bounds_lons)
map_center_lat = (lat_min + lat_max) / 2.0
cos_lat = max(float(np.cos(np.radians(map_center_lat))), 1e-6)
lat_pad = max((lat_max - lat_min) * 0.15, 0.01)
lon_pad = max((lon_max - lon_min) * 0.15, 0.01)
lon_pad = max((lon_max - lon_min) * 0.15, 0.01 / cos_lat)
lat_min -= lat_pad
lat_max += lat_pad
lon_min -= lon_pad
@ -298,50 +306,59 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D
lat_max = center_lat + pad
lon_min = center_lon - pad
lon_max = center_lon + pad
map_center_lat = center_lat
add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max)
figure_width, figure_height = _compute_figure_dimensions(
map_center_lat,
lon_min,
lon_max,
lat_min,
lat_max,
)
fig.update_layout(
font=dict(size=18),
title=dict(text=s.storm_cells, font=dict(size=28)),
xaxis_title=s.map_longitude,
yaxis_title=s.map_latitude,
xaxis=dict(
title=dict(text=s.map_longitude, font=dict(size=28)),
tickfont=dict(size=22),
range=[lon_min, lon_max],
showgrid=False,
gridwidth=1,
gridcolor='lightgray',
zeroline=False,
tickfont=dict(size=22),
title_font=dict(size=28),
),
yaxis=dict(
range=[lat_min, lat_max],
showgrid=False,
gridwidth=1,
gridcolor='lightgray',
zeroline=False,
title=dict(text=s.map_latitude, font=dict(size=28)),
tickfont=dict(size=22),
title_font=dict(size=28),
range=[lat_min, lat_max],
scaleanchor='x',
scaleratio=_geographic_yaxis_scaleratio(map_center_lat),
showgrid=False,
zeroline=False,
),
plot_bgcolor='white',
paper_bgcolor='white',
showlegend=True,
legend=dict(
title=dict(text=s.map_legend, font=dict(size=24)),
font=dict(size=20),
title=dict(text=s.map_legend, font=dict(size=22)),
font=dict(size=18),
orientation='h',
x=0.5,
xanchor='center',
y=-0.18,
y=-0.08,
yanchor='top',
bgcolor='rgba(255, 255, 255, 0.8)',
bordercolor='black',
borderwidth=1,
),
width=800,
height=900,
margin=dict(l=70, r=40, t=50, b=130),
width=figure_width,
height=figure_height,
margin=dict(
l=COORDINATE_PLANE_MARGIN_L,
r=COORDINATE_PLANE_MARGIN_R,
t=COORDINATE_PLANE_MARGIN_T,
b=COORDINATE_PLANE_MARGIN_B,
),
)
return fig