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, get_analysis_radius_m,
) )
from src.analysis.geospatial import haversine_distance, haversine_distance_vectorized 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 ( from src.visualization.storm_cells import (
create_storm_cells_map, create_storm_cells_map,
create_storm_cells_summary, 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 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: def _add_header_logo(doc: Document) -> None:
logo_path = _resolve_logo_path("iklim.png") logo_path = _resolve_logo_path("iklim.png")
if not os.path.isfile(logo_path): 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) 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) 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)) doc.add_picture(stream, width=Inches(width_inches))
@ -695,6 +751,7 @@ def create_docx_report(
# Turbine information # Turbine information
_add_title(doc, s.turbine_information, size_pt=16, align=WD_ALIGN_PARAGRAPH.LEFT) _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_paragraph(doc, s.turbine_information_desc, size_pt=10)
_add_table(doc, build_turbine_information_table_data(turbine_df, lang)) _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: if start_date and end_date:
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) _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) 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() doc.add_page_break()
_add_title(doc, s.cloud_to_ground_current_vs_distance, size_pt=14) _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: if start_date and end_date:
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) _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) 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() doc.add_page_break()
_add_title(doc, s.intercloud_current_vs_distance, size_pt=14) _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_title(doc, s.storm_cells, size_pt=14)
_add_paragraph(doc, s.storm_cells_daily_viz, size_pt=10) _add_paragraph(doc, s.storm_cells_daily_viz, size_pt=10)
storm_fig = create_storm_cells_map(storm_data, turbine_df, lang=lang) 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( _add_image_from_bytes(
doc, doc,
storm_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), render_coordinate_plane_png(storm_fig),
content_width, content_width,
map_height,
) )
doc.add_page_break() doc.add_page_break()

View File

@ -216,8 +216,13 @@ class ReportStrings:
map_storm_cells: str map_storm_cells: str
map_cg_lightning: str map_cg_lightning: str
map_ic_lightning: str map_ic_lightning: str
map_lightning_ring_range: str
map_lightning_ring_beyond: str
map_distance_ring: str map_distance_ring: str
map_severity_legend: 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] storm_severity_names: dict[str, str]
hist_minutes_from_start: str hist_minutes_from_start: str
hist_lightning_count: str hist_lightning_count: str
@ -439,8 +444,13 @@ _STRINGS_EN = ReportStrings(
map_storm_cells="Storm Cells", map_storm_cells="Storm Cells",
map_cg_lightning="Cloud-to-Ground Lightning", map_cg_lightning="Cloud-to-Ground Lightning",
map_ic_lightning="Intercloud 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_distance_ring="{radius:.1f}km Distance Ring",
map_severity_legend="{severity} Severity", 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"}, storm_severity_names={"high": "High", "medium": "Medium", "low": "Low", "unknown": "Unknown"},
hist_minutes_from_start="Minutes from start", hist_minutes_from_start="Minutes from start",
hist_lightning_count="Lightning Count", hist_lightning_count="Lightning Count",
@ -671,8 +681,13 @@ _STRINGS_TR = ReportStrings(
map_storm_cells="Fırtına Hücreleri", map_storm_cells="Fırtına Hücreleri",
map_cg_lightning="Yıldırım", map_cg_lightning="Yıldırım",
map_ic_lightning="Şimşek", 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_distance_ring="{radius:.1f} km Mesafe Halkası",
map_severity_legend="{severity} Şiddet", 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"}, 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_minutes_from_start="Başlangıçtan itibaren geçen dakika",
hist_lightning_count="Yıldırım Sayısı", 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_LIGHTNING_CURRENT_SCALE = 800
COORDINATE_PLANE_TURBINE_NAME_FONT_SIZE = 9 COORDINATE_PLANE_TURBINE_NAME_FONT_SIZE = 9
COORDINATE_PLANE_TURBINE_TEXT_POSITION = 'middle center' 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: 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']
@ -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), line=dict(color=color, width=COORDINATE_PLANE_RING_LINE_WIDTH),
opacity=0.6, opacity=0.6,
name=s.map_distance_ring.format(radius=radius / 1000), name=s.map_distance_ring.format(radius=radius / 1000),
showlegend=True showlegend=False
)) ))
# Add turbines (middle layer) # 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] ic_lightning_df = lightning_df[ic_mask]
if len(ic_lightning_df) > 0: if len(ic_lightning_df) > 0:
# Plot intercloud lightnings (foreground layer - always on top) ring_indices = _lightning_ring_indices_for_strikes(
lightning_colors = [] turbine_lat,
if precomputed is not None and 'ring_idx' in precomputed and len(precomputed['ring_idx']) == len(lightning_df): turbine_lon,
mask_ic = (lightning_df['p_type'].astype(str) != '0').values ic_lightning_df,
ring_idx = precomputed['ring_idx'][mask_ic] lightning_df,
for ri in ring_idx: precomputed,
if 0 <= int(ri) < len(config.ring_colors): ic_mask.values if hasattr(ic_mask, 'values') else ic_mask,
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)
lightning_sizes = np.clip( lightning_sizes = np.clip(
ic_lightning_df['current_abs'] / COORDINATE_PLANE_LIGHTNING_CURRENT_SCALE, ic_lightning_df['current_abs'] / COORDINATE_PLANE_LIGHTNING_CURRENT_SCALE,
COORDINATE_PLANE_LIGHTNING_SIZE_MIN, COORDINATE_PLANE_LIGHTNING_SIZE_MIN,
COORDINATE_PLANE_LIGHTNING_SIZE_MAX, COORDINATE_PLANE_LIGHTNING_SIZE_MAX,
) )
_add_ring_colored_lightning_traces(
fig.add_trace(go.Scatter( fig,
x=ic_lightning_df['lng'], # X-axis = Longitude ic_lightning_df,
y=ic_lightning_df['lat'], # Y-axis = Latitude ring_indices,
mode='markers', lightning_sizes,
marker=dict( s.map_ic_lightning,
size=lightning_sizes, s,
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
))
# Calculate axis limits # Calculate axis limits
max_radius_deg = max(config.distance_rings) / 111000 lat_min, lat_max, lon_min, lon_max = _coordinate_plane_bounds(
turbine_lat,
lat_min = turbine_lat - max_radius_deg * 1.5 turbine_lon,
lat_max = turbine_lat + max_radius_deg * 1.5 max(config.distance_rings),
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) add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max)
_apply_coordinate_plane_layout(
fig.update_layout( fig,
title=s.map_ic_plane_title.format(name=turbine_row["name"]), s.map_ic_plane_title.format(name=turbine_row["name"]),
xaxis=dict( s,
title=dict(text=s.map_longitude, font=dict(size=28)), # x-axis title font size turbine_lat,
tickfont=dict(size=22), # x-axis tick label font size lon_min,
range=[lon_min, lon_max], lon_max,
showgrid=False, lat_min,
gridwidth=1, lat_max,
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
) )
return fig 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), line=dict(color=color, width=COORDINATE_PLANE_RING_LINE_WIDTH),
opacity=0.6, opacity=0.6,
name=s.map_distance_ring.format(radius=radius / 1000), name=s.map_distance_ring.format(radius=radius / 1000),
showlegend=True showlegend=False
)) ))
# Add turbines (middle layer) # 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] cg_lightning_df = lightning_df[cg_mask]
if len(cg_lightning_df) > 0: if len(cg_lightning_df) > 0:
# Plot cloud-to-ground lightnings (foreground layer - always on top) ring_indices = _lightning_ring_indices_for_strikes(
lightning_colors = [] turbine_lat,
if precomputed is not None and 'ring_idx' in precomputed and len(precomputed['ring_idx']) == len(lightning_df): turbine_lon,
mask_cg = (lightning_df['p_type'].astype(str) == '0').values cg_lightning_df,
ring_idx = precomputed['ring_idx'][mask_cg] lightning_df,
for ri in ring_idx: precomputed,
if 0 <= int(ri) < len(config.ring_colors): cg_mask.values if hasattr(cg_mask, 'values') else cg_mask,
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)
lightning_sizes = np.clip( lightning_sizes = np.clip(
cg_lightning_df['current_abs'] / COORDINATE_PLANE_LIGHTNING_CURRENT_SCALE, cg_lightning_df['current_abs'] / COORDINATE_PLANE_LIGHTNING_CURRENT_SCALE,
COORDINATE_PLANE_LIGHTNING_SIZE_MIN, COORDINATE_PLANE_LIGHTNING_SIZE_MIN,
COORDINATE_PLANE_LIGHTNING_SIZE_MAX, COORDINATE_PLANE_LIGHTNING_SIZE_MAX,
) )
_add_ring_colored_lightning_traces(
fig.add_trace(go.Scatter( fig,
x=cg_lightning_df['lng'], # X-axis = Longitude cg_lightning_df,
y=cg_lightning_df['lat'], # Y-axis = Latitude ring_indices,
mode='markers', lightning_sizes,
marker=dict( s.map_cg_lightning,
size=lightning_sizes, s,
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
))
# Calculate axis limits # Calculate axis limits
max_radius_deg = max(config.distance_rings) / 111000 lat_min, lat_max, lon_min, lon_max = _coordinate_plane_bounds(
turbine_lat,
lat_min = turbine_lat - max_radius_deg * 1.5 turbine_lon,
lat_max = turbine_lat + max_radius_deg * 1.5 max(config.distance_rings),
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) add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max)
_apply_coordinate_plane_layout(
fig.update_layout( fig,
title=s.map_cg_plane_title.format(name=turbine_row["name"]), s.map_cg_plane_title.format(name=turbine_row["name"]),
xaxis=dict( s,
title=dict(text=s.map_longitude, font=dict(size=28)), # x-axis title font size turbine_lat,
tickfont=dict(size=22), # x-axis tick label font size lon_min,
range=[lon_min, lon_max], lon_max,
showgrid=False, lat_min,
gridwidth=1, lat_max,
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
) )
return fig 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.utils import parse_period_string_to_datetime
from src.visualization.basemap import add_satellite_basemap from src.visualization.basemap import add_satellite_basemap
from src.visualization.maps import ( 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_NAME_FONT_SIZE,
COORDINATE_PLANE_TURBINE_TEXT_POSITION, COORDINATE_PLANE_TURBINE_TEXT_POSITION,
_compute_figure_dimensions,
_geographic_yaxis_scaleratio,
) )
def format_datetime_for_display(datetime_str: str) -> str: 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) lat_max = max(bounds_lats)
lon_min = min(bounds_lons) lon_min = min(bounds_lons)
lon_max = max(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) 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_min -= lat_pad
lat_max += lat_pad lat_max += lat_pad
lon_min -= lon_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 lat_max = center_lat + pad
lon_min = center_lon - pad lon_min = center_lon - pad
lon_max = 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) 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( fig.update_layout(
font=dict(size=18), font=dict(size=18),
title=dict(text=s.storm_cells, font=dict(size=28)), title=dict(text=s.storm_cells, font=dict(size=28)),
xaxis_title=s.map_longitude,
yaxis_title=s.map_latitude,
xaxis=dict( xaxis=dict(
title=dict(text=s.map_longitude, font=dict(size=28)),
tickfont=dict(size=22),
range=[lon_min, lon_max], range=[lon_min, lon_max],
showgrid=False, showgrid=False,
gridwidth=1,
gridcolor='lightgray',
zeroline=False, zeroline=False,
tickfont=dict(size=22),
title_font=dict(size=28),
), ),
yaxis=dict( yaxis=dict(
range=[lat_min, lat_max], title=dict(text=s.map_latitude, font=dict(size=28)),
showgrid=False,
gridwidth=1,
gridcolor='lightgray',
zeroline=False,
tickfont=dict(size=22), 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', plot_bgcolor='white',
paper_bgcolor='white', paper_bgcolor='white',
showlegend=True, showlegend=True,
legend=dict( legend=dict(
title=dict(text=s.map_legend, font=dict(size=24)), title=dict(text=s.map_legend, font=dict(size=22)),
font=dict(size=20), font=dict(size=18),
orientation='h', orientation='h',
x=0.5, x=0.5,
xanchor='center', xanchor='center',
y=-0.18, y=-0.08,
yanchor='top', yanchor='top',
bgcolor='rgba(255, 255, 255, 0.8)', bgcolor='rgba(255, 255, 255, 0.8)',
bordercolor='black', bordercolor='black',
borderwidth=1, borderwidth=1,
), ),
width=800, width=figure_width,
height=900, height=figure_height,
margin=dict(l=70, r=40, t=50, b=130), margin=dict(
l=COORDINATE_PLANE_MARGIN_L,
r=COORDINATE_PLANE_MARGIN_R,
t=COORDINATE_PLANE_MARGIN_T,
b=COORDINATE_PLANE_MARGIN_B,
),
) )
return fig return fig