diff --git a/src/analysis/histogram.py b/src/analysis/histogram.py index 9134316..b30f424 100644 --- a/src/analysis/histogram.py +++ b/src/analysis/histogram.py @@ -28,6 +28,29 @@ def filter_lightning_by_distance(lightning_df, centroid_lat, centroid_lng): mask = dists <= max_distance return lightning_df.loc[mask].copy() +def get_peak_activity(lightning_df, centroid_lat, centroid_lng): + """ + Return the single busiest minute within the analysis radius. + + Returns a dict {'when': 'DD-MM-YYYY HH:MM', 'count': int} or None when there + is no activity to summarize. + """ + filtered_df = filter_lightning_by_distance(lightning_df, centroid_lat, centroid_lng) + if len(filtered_df) == 0: + return None + + f = filtered_df.copy() + if f['local_time'].dtype == 'object': + f['local_time'] = pd.to_datetime(f['local_time']) + + f['minute_rounded'] = f['local_time'].dt.floor('min') + counts = f.groupby('minute_rounded').size() + if counts.empty: + return None + + peak_minute = counts.idxmax() + return {'when': peak_minute.strftime('%d-%m-%Y %H:%M'), 'count': int(counts.max())} + def find_activity_periods(df, min_gap_minutes=30, min_events_per_period=10): """ Find concentrated activity periods based on time gaps. @@ -198,7 +221,7 @@ def _build_histogram_figure_for_periods(periods_chunk, max_distance_km, lang=Non period_str = f"{start.strftime('%d-%m-%Y %H:%M')}-{end.strftime('%d-%m-%Y %H:%M')}" # Use line breaks to fit within histogram width - return f"Date: {date_str}
Period: {period_str}
Total lightnings: {event_count}" + return s.hist_period_title.format(date=date_str, period=period_str, total=event_count) fig = make_subplots( rows=n_rows, @@ -226,7 +249,7 @@ def _build_histogram_figure_for_periods(periods_chunk, max_distance_km, lang=Non if p_type in minute_counts.columns: counts = minute_counts[p_type].values - p_type_name = "Cloud-to-Ground" if p_type == '0' else "Intercloud" + p_type_name = s.hist_cg_legend if p_type == '0' else s.hist_ic_legend fig.add_trace( go.Bar( diff --git a/src/reporting/docx.py b/src/reporting/docx.py index 6140240..63cdc32 100644 --- a/src/reporting/docx.py +++ b/src/reporting/docx.py @@ -18,7 +18,7 @@ from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Cm, Inches, Pt, RGBColor -from src.analysis.histogram import create_lightning_histogram_pages +from src.analysis.histogram import create_lightning_histogram_pages, get_peak_activity from src.analysis.risk import calculate_turbine_risks from src.analysis.statistics import calculate_lightning_statistics from src.config import config @@ -36,7 +36,7 @@ from src.utils import ( now_in_timezone, get_analysis_radius_m, ) -from src.analysis.geospatial import haversine_distance +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.storm_cells import ( create_storm_cells_map, @@ -169,6 +169,103 @@ def _add_bullets(doc: Document, items: Iterable[str], size_pt: int = 10) -> None r.font.size = Pt(size_pt) +def _build_key_findings( + lightning_df: pd.DataFrame, + turbine_df: pd.DataFrame, + stats: dict[str, Any], + centroid_lat: float, + centroid_lng: float, + s, + storm_cell_count: int = 0, + storm_closest_to_centroid_km: float | None = None, +) -> tuple[list[str], str]: + radius_m = get_analysis_radius_m() + if radius_m <= 0: + radius_m = int(max(config.distance_rings)) if config.distance_rings else 0 + innermost_km = (float(min(config.distance_rings)) / 1000.0) if config.distance_rings else (radius_m / 1000.0) + + turbine_count = len(turbine_df) + total_events = int(stats.get("total_events", 0) or 0) + + cg_total = 0 + ic_total = 0 + innermost_count = 0 + within_df = lightning_df.iloc[0:0] + if len(lightning_df) > 0 and radius_m > 0: + dists_m = haversine_distance_vectorized( + centroid_lat, + centroid_lng, + lightning_df["lat"].values, + lightning_df["lng"].values, + ) + within_mask = dists_m <= radius_m + within_df = lightning_df.loc[within_mask].copy() + within_dists = dists_m[within_mask] + ptype = within_df["p_type"].astype(str) + cg_total = int((ptype == "0").sum()) + ic_total = int((ptype == "1").sum()) + innermost_count = int((within_dists <= (innermost_km * 1000.0)).sum()) + + strongest_ka: float | None = None + nearest_km: float | None = None + nearest_turbine: str | None = None + if len(within_df) > 0 and "current" in within_df.columns: + cg_df = within_df[within_df["p_type"].astype(str) == "0"] + if len(cg_df) > 0: + currents = pd.to_numeric(cg_df["current"], errors="coerce").abs() + if bool(currents.notna().any()): + strongest_ka = float(currents.max()) / 1000.0 + if turbine_count > 0 and {"lat", "lng"}.issubset(turbine_df.columns): + cg_lat = cg_df["lat"].values + cg_lng = cg_df["lng"].values + best = float("inf") + best_name: str | None = None + for _, t in turbine_df.iterrows(): + d = haversine_distance_vectorized(float(t["lat"]), float(t["lng"]), cg_lat, cg_lng) + m = float(d.min()) + if m < best: + best = m + best_name = str(t.get("name", "N/A")) + if best != float("inf"): + nearest_km = best / 1000.0 + nearest_turbine = best_name + + high_plus_count = 0 + if "risk_log" in turbine_df.columns and turbine_count > 0: + high_plus_count = int((pd.to_numeric(turbine_df["risk_log"], errors="coerce") >= 0.8).sum()) + + active_days = len(stats.get("daily_lightning_by_rings") or {}) + peak = get_peak_activity(lightning_df, centroid_lat, centroid_lng) + + bullets: list[str] = [ + s.kf_total_events.format(total=total_events, cg=cg_total, ic=ic_total), + s.kf_innermost.format(radius=innermost_km, count=innermost_count), + ] + if peak: + bullets.append(s.kf_peak_activity.format(when=peak["when"], count=peak["count"])) + if strongest_ka is not None: + bullets.append(s.kf_strongest_strike.format(ka=strongest_ka)) + if nearest_km is not None and nearest_turbine is not None: + bullets.append(s.kf_nearest_strike.format(distance=nearest_km, name=nearest_turbine)) + if turbine_count > 0: + bullets.append(s.kf_high_risk_turbines.format(count=high_plus_count, total=turbine_count)) + if active_days > 1: + bullets.append(s.kf_active_days.format(days=active_days)) + if storm_cell_count > 0 and storm_closest_to_centroid_km is not None: + bullets.append( + s.kf_storm_distance.format(count=storm_cell_count, distance=storm_closest_to_centroid_km) + ) + + if total_events == 0: + recommendation = s.recommendation_no_activity + elif high_plus_count > 0: + recommendation = s.recommendation_high_plus.format(count=high_plus_count, total=turbine_count) + else: + recommendation = s.recommendation_routine + + return bullets, recommendation + + def _lighten_hex_color(hex_color: str, factor: float = 0.6) -> str: s = hex_color.strip() if s.startswith("#"): @@ -602,6 +699,26 @@ def create_docx_report( storm_closest_distance_km: float | None = None storm_over_threshold_km = 1.0 + # Closest storm-cell centroid to the farm centroid (independent of turbines): lets the + # report comment on detected storms even when no turbine was directly affected. + storm_cell_count = len(storm_data) if storm_data else 0 + storm_closest_to_centroid_km: float | None = None + if storm_data: + min_centroid_dist = float("inf") + for storm in storm_data: + wkt_string = storm.get("cell_polygon_wkt", "") or "" + if not wkt_string: + continue + centroid = calculate_storm_cell_centroid(wkt_string) + if centroid is None: + continue + c_lat, c_lng = centroid + dist_km = haversine_distance(centroid_lat, centroid_lng, c_lat, c_lng) / 1000.0 + if dist_km < min_centroid_dist: + min_centroid_dist = dist_km + if min_centroid_dist != float("inf"): + storm_closest_to_centroid_km = min_centroid_dist + if storm_data and top_turbine_name is not None and "risk_log" in turbine_df.columns and len(turbine_df) > 0: # Use the coordinates of the highest-risk turbine for storm-distance calculations. if "lat" in turbine_df.columns and "lng" in turbine_df.columns and top_turbine_name is not None: @@ -645,6 +762,9 @@ def create_docx_report( "storm_near_turbine_count": storm_near_turbine_count, "storm_closest_distance_km": round(float(storm_closest_distance_km), 1) if storm_closest_distance_km is not None else None, "storm_over_threshold_km": storm_over_threshold_km, + "storm_detected": storm_cell_count > 0, + "storm_cell_count": storm_cell_count, + "storm_closest_to_centroid_km": round(float(storm_closest_to_centroid_km), 1) if storm_closest_to_centroid_km is not None else None, "turbine_risk_counts": turbine_risk_counts, "storm_summary": storm_summary, } @@ -656,16 +776,31 @@ def create_docx_report( if commentary_text.startswith(prefix): commentary_text = commentary_text[len(prefix):].strip() break + key_findings, recommendation_text = _build_key_findings( + lightning_df, + turbine_df, + stats, + centroid_lat, + centroid_lng, + s, + storm_cell_count=storm_cell_count, + storm_closest_to_centroid_km=storm_closest_to_centroid_km, + ) + # Keep the commentary close to the Turbine Information table (same page if possible). doc.add_paragraph("") doc.add_paragraph("") _add_title(doc, s.report_summary, size_pt=14, bold=True, align=WD_ALIGN_PARAGRAPH.LEFT) + _add_paragraph(doc, s.key_findings_heading, size_pt=11, bold=True) + _add_bullets(doc, key_findings, size_pt=10) # DOCX line spacing for the Gemini commentary paragraph. p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.LEFT r = p.add_run(commentary_text) r.font.size = Pt(10) p.paragraph_format.line_spacing = 1.5 + _add_paragraph(doc, s.recommendation_heading, size_pt=11, bold=True) + _add_paragraph(doc, recommendation_text, size_pt=10) doc.add_page_break() @@ -692,6 +827,7 @@ def create_docx_report( _add_title(doc, s.cloud_to_ground_current_vs_distance, size_pt=14) if start_date and end_date: _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) + _add_paragraph(doc, s.cg_current_vs_distance_desc, size_pt=10) cg_chart = _build_current_vs_distance_chart( lightning_df, pre["dists_km"], @@ -717,6 +853,7 @@ def create_docx_report( _add_title(doc, s.intercloud_current_vs_distance, size_pt=14) if start_date and end_date: _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) + _add_paragraph(doc, s.ic_current_vs_distance_desc, size_pt=10) ic_chart = _build_current_vs_distance_chart( lightning_df, pre["dists_km"], @@ -734,6 +871,7 @@ def create_docx_report( risk_table_data, risk_row_colors = build_risk_table_data(turbine_df, lang) if risk_table_data and len(risk_table_data) > 1: _add_title(doc, s.turbine_risk_assessment, size_pt=14) + _add_paragraph(doc, s.turbine_risk_assessment_desc, size_pt=10) _add_table(doc, risk_table_data, row_colors=risk_row_colors) doc.add_page_break() @@ -797,8 +935,6 @@ def create_docx_report( size_pt=9, ) - _add_paragraph(doc, s.ring_section_intro, size_pt=10) - _add_paragraph(doc, s.ring_closer_risk.format(closest=closest_km), size_pt=10) ring_items: list[str] = [] for ring_name, ring_data in sorted((stats.get("lightning_by_distance_rings") or {}).items()): if ring_data.get("total", 0) > 0: @@ -854,9 +990,6 @@ def create_docx_report( size_pt=10, ) - _add_paragraph(doc, s.avg_direction.format(value=summary.get("avg_direction", 0)), size_pt=10) - _add_paragraph(doc, s.avg_speed.format(value=summary.get("avg_speed", 0)), size_pt=10) - daily_breakdown: dict[str, int] = summary.get("daily_breakdown", {}) or {} _add_paragraph(doc, s.daily_storm_breakdown, size_pt=10, bold=True) sorted_days = sorted(daily_breakdown.keys()) diff --git a/src/reporting/gemini_commentary.py b/src/reporting/gemini_commentary.py index 3fdd5af..7b6de04 100644 --- a/src/reporting/gemini_commentary.py +++ b/src/reporting/gemini_commentary.py @@ -104,6 +104,8 @@ def build_gemini_prompt(context: dict[str, Any], language: ReportLanguage | None storm_near_turbine_count = context.get("storm_near_turbine_count") storm_closest_distance_km = context.get("storm_closest_distance_km") storm_over_threshold_km = context.get("storm_over_threshold_km", 1.0) + storm_detected = context.get("storm_detected") + storm_closest_to_centroid_km = context.get("storm_closest_to_centroid_km") ring_lines = _ring_lines(context, s) storm_lines = _storm_lines(context) @@ -142,6 +144,8 @@ def build_gemini_prompt(context: dict[str, Any], language: ReportLanguage | None f"- storm_near_turbine_count: {storm_near_turbine_count}\n" f"- storm_closest_distance_km: {storm_closest_distance_km}\n" f"- storm_over_threshold_km: {storm_over_threshold_km}\n" + f"- storm_detected: {storm_detected}\n" + f"- storm_closest_to_centroid_km: {storm_closest_to_centroid_km}\n" + (f"\n- storm_summary:\n{chr(10).join(storm_lines)}" if storm_lines else "\n- storm_summary: not available") + "\n\n" "Paragraf gereksinimleri:\n" @@ -150,7 +154,9 @@ def build_gemini_prompt(context: dict[str, Any], language: ReportLanguage | None "- is_single_turbine_report true ise: \"{top_turbine_name} türbini için log-risk skoru ... sınıfındadır\" gibi doğal bir ifade kullan.\n" "- is_single_turbine_report false ise: analiz alanında en yüksek riskli türbini ve risk sınıfını belirt.\n" "- top_turbine_name adını aynen kullan ve max_risk_definition ile ilişkilendir.\n" - "- Fırtına hücresi etkileşimini uygun olduğunda belirt; veri yoksa kısaca belirt.\n" + "- Fırtına hücresi etkileşimini yalnızca veri varken belirt.\n" + "- Fırtına tespit edildiyse (storm_detected true) ancak hiçbir türbinin üzerine gelmediyse, en yakın fırtına hücresinin merkeze uzaklığını (storm_closest_to_centroid_km) belirt.\n" + "- storm_detected false ise fırtınadan hiç bahsetme.\n" "- Sayıları yuvarla: yoğunluk 3 ondalık, log risk 2 ondalık, mesafeler 1 ondalık km, sayılar tam sayı.\n" "- Ton analitik, net ve alarmist olmayan olsun.\n" "\n" @@ -189,6 +195,8 @@ def build_gemini_prompt(context: dict[str, Any], language: ReportLanguage | None f"- storm_near_turbine_count: {storm_near_turbine_count}\n" f"- storm_closest_distance_km: {storm_closest_distance_km}\n" f"- storm_over_threshold_km: {storm_over_threshold_km}\n" + f"- storm_detected: {storm_detected}\n" + f"- storm_closest_to_centroid_km: {storm_closest_to_centroid_km}\n" + (f"\n- storm_summary:\n{chr(10).join(storm_lines)}" if storm_lines else "\n- storm_summary: not available") + "\n\n" "Requirements for the paragraph:\n" @@ -202,6 +210,8 @@ def build_gemini_prompt(context: dict[str, Any], language: ReportLanguage | None "- Mention storm-cell interaction with the turbine when storm information is available:\n" " - If storm_over_turbine is true: say that storm cells were very close to/over the turbine (based on centroid distance <= storm_over_threshold_km).\n" " - If storm_over_turbine is false and storm_closest_distance_km is provided: say the closest storm cell centroid came within storm_closest_distance_km km of the turbine.\n" + " - If storms were detected (storm_detected true) but did not pass over any turbine, mention the closest storm cell's distance to the centroid (storm_closest_to_centroid_km).\n" + " - If storm_detected is false, do not mention storms at all.\n" "- If storm_summary is available, mention total storm cells and at least one severity count.\n" "- Round numeric values as follows (use the rounded values you are given, avoid long decimals):\n" " - lightning density to 3 decimals (events/km²)\n" @@ -231,6 +241,7 @@ def fallback_commentary(context: dict[str, Any], language: ReportLanguage | None storm_closest_distance_km = context.get("storm_closest_distance_km") storm_over_threshold_km = context.get("storm_over_threshold_km", 1.0) storm_near_turbine_count = context.get("storm_near_turbine_count") + storm_closest_to_centroid_km = context.get("storm_closest_to_centroid_km") outermost_ring = top_rings[-1] if top_rings else None best_ring = top_rings[0] if top_rings else None @@ -243,13 +254,14 @@ def fallback_commentary(context: dict[str, Any], language: ReportLanguage | None if severity_counts: severity = max(severity_counts.items(), key=lambda kv: kv[1])[0] count = severity_counts.get(severity, 0) + severity_label = s.storm_severity_names.get(str(severity).strip().lower(), str(severity).title()) if s.gemini_write_turkish: storm_line = ( - f"Toplam {total_cells} fırtına hücresi kaydedilmiş; en fazla {count} hücre {severity} şiddetindedir." + f"Toplam {total_cells} fırtına hücresi kaydedilmiş; en fazla {count} hücre {severity_label} şiddetindedir." ) else: storm_line = ( - f"Storm data indicates {total_cells} storm cells, with the highest share in {severity} ({count} cells)." + f"Storm data indicates {total_cells} storm cells, with the highest share in {severity_label} ({count} cells)." ) elif s.gemini_write_turkish: storm_line = f"Toplam {total_cells} fırtına hücresi kaydedilmiştir." @@ -289,13 +301,18 @@ def fallback_commentary(context: dict[str, Any], language: ReportLanguage | None f"Fırtına hücrelerinden {storm_near_turbine_count} tanesinin merkezi türbine " f"{storm_over_threshold_km:.1f} km'den yakın konumlanmıştır. " ) + elif isinstance(storm_closest_to_centroid_km, (int, float)): + storm_interaction_sentence = ( + f"Tespit edilen fırtına hücreleri herhangi bir türbinin üzerine denk gelmemiş; " + f"en yakın hücre merkeze {storm_closest_to_centroid_km:.1f} km mesafeye kadar yaklaşmıştır. " + ) elif isinstance(storm_closest_distance_km, (int, float)): storm_interaction_sentence = ( f"En yakın fırtına hücresi merkezi türbine {storm_closest_distance_km:.1f} km mesafededir. " ) else: storm_interaction_sentence = "" - storm_severity_sentence = storm_line if storm_line else "Bu raporda fırtına hücresi verisi bulunmamaktadır." + storm_severity_sentence = storm_line else: radius_txt = f"within {analysis_radius_km:.1f} km" if isinstance(analysis_radius_km, (int, float)) else "" events_txt = f"{total_events} total lightning events" if total_events is not None else "N/A" @@ -317,13 +334,17 @@ def fallback_commentary(context: dict[str, Any], language: ReportLanguage | None storm_interaction_sentence = ( f"Storm interaction: storm-cell centroids came within {storm_over_threshold_km:.1f} km of the turbine (count={storm_near_turbine_count}). " ) + elif isinstance(storm_closest_to_centroid_km, (int, float)): + storm_interaction_sentence = ( + f"Detected storm cells did not pass directly over any turbine; the nearest cell came within {storm_closest_to_centroid_km:.1f} km of the centroid. " + ) elif isinstance(storm_closest_distance_km, (int, float)): storm_interaction_sentence = ( f"Storm interaction: the closest storm-cell centroid came within {storm_closest_distance_km:.1f} km of the turbine. " ) else: storm_interaction_sentence = "" - storm_severity_sentence = storm_line if storm_line else "Storm severity distribution is not available for this report." + storm_severity_sentence = storm_line return (paragraph_intro + turbine_sentence + method_sentence + storm_interaction_sentence + storm_severity_sentence).strip() diff --git a/src/reporting/strings.py b/src/reporting/strings.py index 692eff1..3d70520 100644 --- a/src/reporting/strings.py +++ b/src/reporting/strings.py @@ -37,13 +37,29 @@ class ReportStrings: turbine_information: str turbine_information_desc: str report_summary: str + key_findings_heading: str + kf_total_events: str + kf_innermost: str + kf_peak_activity: str + kf_strongest_strike: str + kf_nearest_strike: str + kf_high_risk_turbines: str + kf_active_days: str + kf_storm_distance: str + recommendation_heading: str + recommendation_high_plus: str + recommendation_routine: str + recommendation_no_activity: str cloud_to_ground_lightnings: str cloud_to_ground_current_vs_distance: str cg_chart_title: str + cg_current_vs_distance_desc: str intercloud_lightnings: str intercloud_current_vs_distance: str ic_chart_title: str + ic_current_vs_distance_desc: str turbine_risk_assessment: str + turbine_risk_assessment_desc: str lightning_breakdown_rings: str period_total_events: str area_covered: str @@ -192,8 +208,13 @@ class ReportStrings: map_cg_lightning: str map_ic_lightning: str map_distance_ring: str + map_severity_legend: str + storm_severity_names: dict[str, str] hist_minutes_from_start: str hist_lightning_count: str + hist_period_title: str + hist_cg_legend: str + hist_ic_legend: str storm_cells_day_title: str heatmap_title: str heatmap_subtitle: str @@ -223,13 +244,29 @@ _STRINGS_EN = ReportStrings( turbine_information="Turbine Information", turbine_information_desc="This table contains detailed information about all turbines in the wind farm.", report_summary="Report Summary", + key_findings_heading="Key Findings", + kf_total_events="Total events: {total} ({cg} cloud-to-ground, {ic} intercloud)", + kf_innermost="Events within {radius:.1f} km of the centroid: {count}", + kf_peak_activity="Peak activity: {when} ({count} events in one minute)", + kf_strongest_strike="Strongest cloud-to-ground strike: {ka:.1f} kA", + kf_nearest_strike="Nearest cloud-to-ground strike to a turbine: {distance:.1f} km ({name})", + kf_high_risk_turbines="Turbines at High risk or above: {count} of {total}", + kf_active_days="Days with recorded activity: {days}", + kf_storm_distance="Storm cells detected: {count} (closest {distance:.1f} km from the centroid)", + recommendation_heading="Recommendation", + recommendation_high_plus="{count} of {total} turbines fall in the High risk category or above. Inspection of the lightning protection systems on these turbines is recommended.", + recommendation_routine="No turbines fall in the High risk category or above; routine monitoring is sufficient for this period.", + recommendation_no_activity="No lightning activity was recorded within the analysis area during this period.", cloud_to_ground_lightnings="Cloud-to-Ground Lightnings", cloud_to_ground_current_vs_distance="Cloud-to-Ground — Current vs Distance", - cg_chart_title="Cloud-to-Ground Lightning — Current vs Distance from Centroid", + cg_chart_title="Cloud-to-Ground Lightning — Current and Distance to Centroid", + cg_current_vs_distance_desc="The chart below shows the distance to the center point and the current magnitude of the cloud-to-ground events that occurred during the analyzed time period.", intercloud_lightnings="Intercloud Lightnings", intercloud_current_vs_distance="Intercloud — Current vs Distance", - ic_chart_title="Intercloud Lightning — Current vs Distance from Centroid", + ic_chart_title="Intercloud Lightning — Current and Distance to Centroid", + ic_current_vs_distance_desc="The chart below shows the distance to the center point and the current magnitude of the intercloud events that occurred during the analyzed time period.", turbine_risk_assessment="Turbine Risk Assessment", + turbine_risk_assessment_desc="You can find the explanation of how the risk assessment is performed in Appendices 1, 2 and 3.", lightning_breakdown_rings="Lightning Breakdown by Distance Rings", period_total_events="Period: {start} - {end} (Total: {total} lightning events)", area_covered="Area covered within {radius:.1f} km radius: {area:.1f} km²", @@ -277,7 +314,7 @@ _STRINGS_EN = ReportStrings( alpha_desc="α (distance decay factor): controls how risk decays with distance (smaller = slower decay)", included_events="Included events: only cloud-to-ground lightning (p_type = 0)", per_strike_contribution="Per-strike contribution: increases with |I| and decreases exponentially with distance", - turbine_risk_sum="Turbine's risk score: sum of per-strike contributions for all included strikes (typically within the outermost distance ring)", + turbine_risk_sum="Turbine's risk score: the sum of per-strike contributions from all cloud-to-ground strikes within the outermost distance ring", log_transformed="For visualization and reporting, we use the log-transformed score", risk_interpretation="2. Risk Score Interpretation", understanding_risk="Understanding Risk Score Values:", @@ -304,7 +341,7 @@ _STRINGS_EN = ReportStrings( centroid_bullet_1="Farm centroid is provided by the monitoring workflow and shared by every map and table in this report", centroid_bullet_2="Distance rings are drawn from the farm centroid; innermost rings represent the highest proximity risk", centroid_bullet_3="Lightning proximity for each strike is measured from the farm centroid using the Haversine formula", - centroid_bullet_4="The monitoring boundary defines the outer analysis radius — strikes beyond it are excluded", + centroid_bullet_4="The analysis is limited to the outermost distance ring; strikes beyond it are excluded from all statistics and charts", freq_lightning_algo="5. Frequent Lightning Activity Period Detection Algorithm", how_period_timespans="How Period Timespans Are Determined:", gap_based_algo="The algorithm uses a gap-based approach to identify concentrated lightning activity periods.", @@ -371,15 +408,20 @@ _STRINGS_EN = ReportStrings( map_longitude="Longitude", map_latitude="Latitude", map_legend="Legend", - map_cg_plane_title="Cloud-to-Ground Lightning - Coordinate Plane View - Central Turbine: {name}", - map_ic_plane_title="Intercloud Lightning - Coordinate Plane View - Central Turbine: {name}", + map_cg_plane_title="CG Event Distribution", + map_ic_plane_title="IC Event Distribution", map_wind_turbines="Wind Turbines", map_storm_cells="Storm Cells", map_cg_lightning="Cloud-to-Ground Lightning", map_ic_lightning="Intercloud Lightning", map_distance_ring="{radius:.1f}km Distance Ring", + map_severity_legend="{severity} Severity", + storm_severity_names={"high": "High", "medium": "Medium", "low": "Low", "unknown": "Unknown"}, hist_minutes_from_start="Minutes from start", hist_lightning_count="Lightning Count", + hist_period_title="Date: {date}
Period: {period}
Total lightnings: {total}", + hist_cg_legend="Cloud-to-Ground", + hist_ic_legend="Intercloud", storm_cells_day_title="Storm Cells - {day}", heatmap_title="Risk Score Heatmap", heatmap_subtitle="Current Magnitude vs Distance (0.1-{max_km:.1f}km) - Higher values (red) = Higher risk", @@ -418,21 +460,37 @@ _STRINGS_TR = ReportStrings( turbine_information="Türbin Bilgileri", turbine_information_desc="Bu tablo, rüzgar çiftliğindeki tüm türbinlere ait ayrıntılı bilgileri içerir.", report_summary="Rapor Özeti", + key_findings_heading="Öne Çıkan Bulgular", + kf_total_events="Toplam olay: {total} ({cg} yıldırım, {ic} şimşek)", + kf_innermost="Merkeze {radius:.1f} km mesafe içinde: {count} olay", + kf_peak_activity="En yoğun an: {when} (bir dakikada {count} olay)", + kf_strongest_strike="En güçlü yıldırım darbesi: {ka:.1f} kA", + kf_nearest_strike="Bir türbine en yakın yıldırım: {distance:.1f} km ({name})", + kf_high_risk_turbines="Yüksek ve üzeri riskli türbin: {total} türbinden {count} adet", + kf_active_days="Aktivite görülen gün sayısı: {days}", + kf_storm_distance="Tespit edilen fırtına hücresi: {count} (merkeze en yakın {distance:.1f} km)", + recommendation_heading="Öneri", + recommendation_high_plus="{total} türbinden {count} adedi Yüksek risk ve üzeri sınıfta yer almaktadır. Bu türbinlerin yıldırımdan korunma sistemlerinin incelenmesi önerilir.", + recommendation_routine="Yüksek risk ve üzeri sınıfta türbin bulunmamaktadır; bu dönem için rutin izleme yeterlidir.", + recommendation_no_activity="Bu dönemde analiz alanında yıldırım-şimşek aktivitesi kaydedilmemiştir.", cloud_to_ground_lightnings="Yıldırım", cloud_to_ground_current_vs_distance="Yıldırım — Akım ve Mesafe", - cg_chart_title="Şimşek — Merkezden Akım ve Mesafe", + cg_chart_title="Yıldırım — Akım ve Merkeze olan Mesafe", + cg_current_vs_distance_desc="Aşağıdaki grafik, incelenen zaman dilimi içinde gerçekleşen yıldırımların merkez noktaya mesafesini ve akım miktarını göstermektedir.", intercloud_lightnings="Şimşek", intercloud_current_vs_distance="Şimşek — Akım ve Mesafe", - ic_chart_title="Şimşek — Merkezden Akım ve Mesafe", + ic_chart_title="Şimşek — Akım ve Merkeze olan Mesafe", + ic_current_vs_distance_desc="Aşağıdaki grafik, incelenen zaman dilimi içinde gerçekleşen şimşeklerin merkez noktaya mesafesini ve akım miktarını göstermektedir.", turbine_risk_assessment="Türbin Risk Değerlendirmesi", + turbine_risk_assessment_desc="Risk değerlendirmesinin nasıl yapıldığına dair açıklamayı Ek. 1, 2 ve 3'te bulabilirsiniz.", lightning_breakdown_rings="Mesafe Halkalarına Göre Yıldırım Dağılımı", - period_total_events="Dönem: {start} - {end} (Toplam: {total} yıldırım olayı)", + period_total_events="Dönem: {start} - {end} (Toplam: {total} yıldırım-şimşek olayı)", area_covered="{radius:.1f} km yarıçap içinde kapsanan alan: {area:.1f} km²", - total_lightnings_radius="{radius:.1f} km yarıçap içindeki toplam yıldırım: {total} olay", - total_density="Toplam yıldırım yoğunluğu: {density:.3f} olay/km²", - density_calc="(Hesaplama: {total} toplam yıldırım / {area:.1f} km² alan)", + total_lightnings_radius="{radius:.1f} km yarıçap içindeki toplam yıldırım-şimşek: {total} olay", + total_density="Toplam yıldırım-şimşek yoğunluğu: {density:.3f} olay/km²", + density_calc="(Hesaplama: {total} toplam yıldırım-şimşek olayı / {area:.1f} km² alan)", daily_density="Günlük eşdeğer yoğunluk: {density:.3f} olay/km²/gün", - daily_density_calc="(Hesaplama: {total} toplam yıldırım / {area:.1f} km² alan / {days_label})", + daily_density_calc="(Hesaplama: {total} toplam yıldırım-şimşek olayı / {area:.1f} km² alan / {days_label})", day_in_period="dönemde 1 gün", days_in_period="dönemde {days} gün", ring_section_intro="Bu bölüm, analiz dönemindeki yıldırım olaylarını türbinlere olan mesafeye göre gösterir.", @@ -440,7 +498,7 @@ _STRINGS_TR = ReportStrings( ring_item="{ring}: {total} toplam ({cg} yıldırım, {ic} şimşek)", frequent_lightning_report="Sık Yıldırım Aktivite Raporu", lightning_activity_overview="Yıldırım Aktivite Genel Bakış:", - histogram_overview="Aşağıdaki grafik, {radius:.1f} km yarıçap içindeki zaman içi yıldırım aktivite örüntülerini göstererek yüksek riskli dönemleri ve yoğun fırtına olaylarını belirlemeye yardımcı olur. Tepe noktaları dikkat gerektiren yoğun yıldırım aktivitesini gösterir.", + histogram_overview="Aşağıdaki grafik, {radius:.1f} km yarıçap içindeki yıldırım-şimşek aktivitesinin zaman içindeki dağılımını göstermekte ve yüksek riskli anların belirlenmesine yardımcı olmaktadır.", histogram_appendix_ref='Ayrıntılı bilgi için ek bölümdeki "Sık Yıldırım Aktivite Dönemi Tespit Algoritması" bölümüne bakınız.', frequent_lightning_periods="Sık Yıldırım Aktivite Dönemleri", storm_cells_analysis_summary="Fırtına Hücreleri Analiz Özeti", @@ -463,16 +521,16 @@ _STRINGS_TR = ReportStrings( appendix="Ek", risk_calc_method="1. Risk Hesaplama Yöntemi", how_risk_determined="Risk Skorları Nasıl Belirlenir:", - risk_exposure_1="Bir türbinin risk skoru, analiz döneminde yıldırıma maruz kalmasını temsil eder.", - risk_exposure_2="Her yıldırım darbesi, türbine olan mesafe ve akım büyüklüğüne göre bir risk katkısı yapar; bu katkılar toplanır.", - p0_desc="P0 (temel faktör): her darbenin katkısına uygulanan temel ölçekleme", + risk_exposure_1="Bir türbinin risk skoru, analiz dönemi boyunca buluttan yere düşen yıldırımlara ne ölçüde maruz kaldığını gösterir.", + risk_exposure_2="Her yıldırım darbesi, türbine olan mesafesine ve akım büyüklüğüne bağlı olarak bir risk katkısı sağlar; bu katkıların tümü toplanarak skor elde edilir.", + p0_desc="P0 (temel faktör): her darbenin katkısına uygulanan temel ölçek katsayısı", current_weight_desc="current_weight: akım büyüklüğünün riski ne kadar artırdığını kontrol eder (büyük = |I| üzerinde daha fazla ağırlık)", distance_desc="Mesafe (d): türbin-darbe mesafesi (kilometre)", current_desc="Akım (|I|): mutlak akım büyüklüğü |I| (amper)", alpha_desc="α (mesafe azalma faktörü): riskin mesafeyle nasıl azaldığını kontrol eder (küçük = daha yavaş azalma)", - included_events="Dahil edilen olaylar: yalnızca yıldırım (p_type = 0)", + included_events="Dahil edilen olaylar: yalnızca buluttan yere düşen yıldırımlar (p_type = 0)", per_strike_contribution="Darbe başına katkı: |I| ile artar, mesafe ile üstel olarak azalır", - turbine_risk_sum="Türbin risk skoru: dahil edilen tüm darbelerin katkılarının toplamı (genellikle en dış mesafe halkası içinde)", + turbine_risk_sum="Türbin risk skoru: en dış mesafe halkası içindeki tüm yıldırım (buluttan yere) darbelerinin katkılarının toplamıdır", log_transformed="Görselleştirme ve raporlama için log dönüşümlü skor kullanılır", risk_interpretation="2. Risk Skoru Yorumu", understanding_risk="Risk Skoru Değerlerini Anlama:", @@ -499,17 +557,17 @@ _STRINGS_TR = ReportStrings( centroid_bullet_1="Çiftlik merkezi izleme iş akışı tarafından sağlanır ve bu rapordaki her harita ve tabloda kullanılır", centroid_bullet_2="Mesafe halkaları çiftlik merkezinden çizilir; en iç halkalar en yüksek yakınlık riskini temsil eder", centroid_bullet_3="Her darbenin yakınlığı, çiftlik merkezinden Haversine formülü ile ölçülür", - centroid_bullet_4="İzleme sınırı dış analiz yarıçapını tanımlar — dışındaki darbeler hariç tutulur", + centroid_bullet_4="Analiz, en dış mesafe halkası ile sınırlıdır; bu halkanın dışında kalan darbeler tüm istatistik ve grafiklerin dışında bırakılır", freq_lightning_algo="5. Sık Yıldırım Aktivite Dönemi Tespit Algoritması", how_period_timespans="Dönem Zaman Aralıkları Nasıl Belirlenir:", - gap_based_algo="Algoritma, yoğun yıldırım aktivite dönemlerini belirlemek için boşluk tabanlı bir yaklaşım kullanır.", + gap_based_algo="Algoritma, yoğun yıldırım aktivitesi dönemlerini belirlemek için olaylar arasındaki zaman boşluklarına dayalı bir yaklaşım kullanır.", step_by_step="Adım Adım Süreç:", algo_chronological="Kronolojik Sıralama: Tüm yıldırım olayları zaman damgasına (local_time) göre sıralanır", algo_gap="Boşluk Hesaplama: Ardışık yıldırım olayları arasındaki zaman farkları hesaplanır", algo_period_boundary="Dönem Sınırı Tespiti: Ardışık iki olay arasındaki boşluk {gap} dakikayı aştığında bir dönem biter ve diğeri başlar", algo_period_validation="Dönem Doğrulama: Yalnızca ≥{min_events} yıldırım olayı içeren dönemler anlamlı kabul edilir", - algo_timespan="Zaman Aralığı Tanımı: Başlangıç = ilk olay; Bitiş = son olay; Süre ilkten sona gerçek süredir", - algo_peak="Tepe Alt-Dönem Tespiti: 3 dakikalık hareketli ortalama; aktivite ortalama + 1 standart sapmayı aştığında tepe; histogramda sarı ile vurgulanır", + algo_timespan="Zaman Aralığı Tanımı: Başlangıç, dönemdeki ilk olay; bitiş ise son olaydır. Süre, ilk olaydan son olaya kadar geçen gerçek zamandır", + algo_peak="Tepe Alt-Dönem Tespiti: 3 dakikalık hareketli ortalama kullanılır; aktivite, ortalamanın 1 standart sapma üzerine çıktığında tepe dönem olarak kabul edilir ve histogramda sarı ile vurgulanır", entln_title="6. EarthNetworks Toplam Yıldırım Ağı (ENTLN)", entln_block_1="Bu rapordaki yıldırım verileri doğrudan EarthNetworks Toplam Yıldırım Ağı'ndan (ENTLN) alınmıştır; hem bulut içi (şimşek) hem buluttan yere (yıldırım) darbeleri izler. ENTLN, küresel ölçekte konuşlandırılan ilk şimşek ve yıldırım tespit ağıdır. 1.500'den fazla sensör içerir.", entln_block_2="Ulusal Hava Durumu Servisi, Hava Kuvvetleri Hava Durumu Ajansı ve kamu güvenliği, acil müdahale, havaalanları ve enerji kuruluşları gibi birçok kurum ENTLN bilgisine güvenir.", @@ -566,15 +624,20 @@ _STRINGS_TR = ReportStrings( map_longitude="Boylam", map_latitude="Enlem", map_legend="Gösterge", - map_cg_plane_title="Yıldırım - Koordinat Düzlemi - Merkez Türbin: {name}", - map_ic_plane_title="Şimşek - Koordinat Düzlemi - Merkez Türbin: {name}", + map_cg_plane_title="Yıldırım Dağılımı", + map_ic_plane_title="Şimşek Dağılımı", map_wind_turbines="Rüzgar Türbinleri", map_storm_cells="Fırtına Hücreleri", map_cg_lightning="Yıldırım", map_ic_lightning="Şimşek", map_distance_ring="{radius:.1f} km Mesafe Halkası", - hist_minutes_from_start="Başlangıçtan dakika", + map_severity_legend="{severity} Şiddet", + 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ı", + hist_period_title="Tarih: {date}
Dönem: {period}
Toplam yıldırım-şimşek: {total}", + hist_cg_legend="Yıldırım", + hist_ic_legend="Şimşek", storm_cells_day_title="Fırtına Hücreleri - {day}", heatmap_title="Risk Skoru Isı Haritası", heatmap_subtitle="Akım Büyüklüğü ve Mesafe (0.1-{max_km:.1f} km) - Yüksek değerler (kırmızı) = Yüksek risk", diff --git a/src/utils.py b/src/utils.py index 8219cea..a628162 100644 --- a/src/utils.py +++ b/src/utils.py @@ -130,11 +130,14 @@ def format_period_display_for_report(start_value: Optional[str], end_value: Opti def get_analysis_radius_m() -> int: from .config import config + rings = config.distance_rings or [] + outermost_ring = int(max(rings)) if rings else 0 boundary = config.analysis_boundary_m if isinstance(boundary, (int, float)) and boundary > 0: + if outermost_ring > 0: + return min(int(boundary), outermost_ring) return int(boundary) - rings = config.distance_rings or [] - return int(max(rings)) if rings else 0 + return outermost_ring def get_turbine_color_by_fixed_intervals(risk_log_value: float) -> str: """ diff --git a/src/visualization/storm_cells.py b/src/visualization/storm_cells.py index bbec64f..fdcf92e 100644 --- a/src/visualization/storm_cells.py +++ b/src/visualization/storm_cells.py @@ -170,7 +170,7 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D mode='lines', line=dict(color=color, width=4), opacity=0.6, - name=f'{radius/1000:.0f}km Ring', + name=s.map_distance_ring.format(radius=radius / 1000), showlegend=True )) @@ -195,7 +195,7 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D text=turbine_df['name'].tolist(), textfont=dict(size=12, color='black'), textposition='middle center', - name='Wind Turbines', + name=s.map_wind_turbines, showlegend=True, hovertemplate=( "Wind Turbine
" @@ -274,7 +274,9 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D y=[None], mode='lines', line=dict(color=color, width=3), - name=f"{severity.title()} Severity", + name=s.map_severity_legend.format( + severity=s.storm_severity_names.get(severity, severity.title()) + ), showlegend=True, hoverinfo='skip' # No hover for legend entries ))