From 3dbd94a0443d9abd0d5e8fe2a2f2c74bc07279bd Mon Sep 17 00:00:00 2001 From: erdemerikci Date: Fri, 19 Jun 2026 13:25:55 +0300 Subject: [PATCH] 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. --- src/reporting/docx.py | 71 +++- src/reporting/strings.py | 15 + src/visualization/maps.py | 613 ++++++++++++++++++++++--------- src/visualization/storm_cells.py | 55 ++- 4 files changed, 560 insertions(+), 194 deletions(-) diff --git a/src/reporting/docx.py b/src/reporting/docx.py index 99a6984..eb54b1d 100644 --- a/src/reporting/docx.py +++ b/src/reporting/docx.py @@ -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() diff --git a/src/reporting/strings.py b/src/reporting/strings.py index 9eadb68..0a4859d 100644 --- a/src/reporting/strings.py +++ b/src/reporting/strings.py @@ -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ı", diff --git a/src/visualization/maps.py b/src/visualization/maps.py index 8cdfb0c..fd49439 100644 --- a/src/visualization/maps.py +++ b/src/visualization/maps.py @@ -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 diff --git a/src/visualization/storm_cells.py b/src/visualization/storm_cells.py index 0f7729c..af7f577 100644 --- a/src/visualization/storm_cells.py +++ b/src/visualization/storm_cells.py @@ -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