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