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
))