Enhance report generation by adding support for multiple languages. Introduce language parameter in payloads and update related functions to handle Turkish and English outputs. Refactor report generation logic to accommodate language-specific strings in various sections, including risk definitions and chart titles.

This commit is contained in:
erdemerikci 2026-06-05 17:08:21 +03:00
parent 17c9aa865a
commit 40f554d190
12 changed files with 1370 additions and 377 deletions

View File

@ -124,6 +124,7 @@ Liveness probe. Returns `{"ok": true, ...}`.
| `strikes` | Lightning strike records (`captured` ISO time preferred) | | `strikes` | Lightning strike records (`captured` ISO time preferred) |
| `storm_records` | Thunderstorm polygons from `/v1/thunderstorms/within` | | `storm_records` | Thunderstorm polygons from `/v1/thunderstorms/within` |
| `gemini_text` | Optional pre-generated commentary | | `gemini_text` | Optional pre-generated commentary |
| `language` | Optional report language: `en` (default) or `tr` |
**Response:** DOCX file (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`). **Response:** DOCX file (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`).
@ -148,6 +149,7 @@ Import `Lightning_Report_Automatic.json` into n8n and configure:
|--------|---------| |--------|---------|
| `customer_name` | Display name | | `customer_name` | Display name |
| `contact_email` | SendGrid recipient | | `contact_email` | SendGrid recipient |
| `language` | Report language: `en` or `tr` (also accepts `turkish`, `Türkçe`) |
| `id` | Linked from wind turbines | | `id` | Linked from wind turbines |
**`wind_turbine_farm`** **`wind_turbine_farm`**
@ -173,7 +175,7 @@ Import `Lightning_Report_Automatic.json` into n8n and configure:
1. Daily trigger → authenticate → loop customers. 1. Daily trigger → authenticate → loop customers.
2. Query lightnings within farm boundary; detect storm window. 2. Query lightnings within farm boundary; detect storm window.
3. Insert/update `storm_logs`, fetch thunderstorms, build payload. 3. Insert/update `storm_logs`, fetch thunderstorms, build payload (include `language` from the customer row).
4. **Generate Report** → upload DOCX to Slack → approval buttons. 4. **Generate Report** → upload DOCX to Slack → approval buttons.
5. Persist pending report fields on `storm_logs`. 5. Persist pending report fields on `storm_logs`.
6. On Slack **Approve** → load queued report → **SendGrid** email → clear pending fields. 6. On Slack **Approve** → load queued report → **SendGrid** email → clear pending fields.
@ -190,6 +192,41 @@ The Slack channel ID in the node does not exist or the bot is not invited. Re-se
Login succeeded but the iklim user has no **account** on the API environment (e.g. test). Ask iklim.co to provision the user, or use a service account that has thunderstorm access. Lightning-only reports can continue if the workflow skips failed thunderstorm calls (optional branch). Login succeeded but the iklim user has no **account** on the API environment (e.g. test). Ask iklim.co to provision the user, or use a service account that has thunderstorm access. Lightning-only reports can continue if the workflow skips failed thunderstorm calls (optional branch).
### ngrok `503` / `ERR_NGROK_3004` on **Generate Report**
ngrok closes long-lived HTTP connections when the report service takes several minutes to respond (Kaleido chart export). The HTML `503` page with `ERR_NGROK_3004` means ngrok never received a complete response from uvicorn.
**Fix:** Re-import `Lightning_Report_Automatic.json`. Report generation uses separate n8n nodes (not one long Code node):
1. **Start Async Report**`POST /generate/async`
2. **Wait for Report** — 15s pause
3. **Poll Report Status**`GET /generate/async/{job_id}` (loops until complete)
4. **Download Report**`GET /generate/async/{job_id}/download`
Restart uvicorn after pulling report-service changes. Update the ngrok URL in the three HTTP nodes when your tunnel URL changes.
### `Task execution timed out after 60 seconds` on report generation
n8n Code nodes are capped at **60 seconds**. Do not put polling loops inside a single Code node. Use the multi-node async flow above instead.
**Alternative:** Skip ngrok and call the service directly (`http://host.docker.internal:8000` from Docker n8n, or `http://127.0.0.1:8000` on the same host).
### `timeout of 300000ms exceeded` on **Generate Report**
The report service can take several minutes when many lightning strikes produce multiple Kaleido chart exports (maps, histograms, heatmaps). n8ns HTTP Request node defaults to **5 minutes (300000 ms)**.
**Fixes (use together):**
1. Re-import `Lightning_Report_Automatic.json` — the **Generate Report** node sets `timeout: 900000` (15 min).
2. On self-hosted n8n, raise the global ceiling if needed:
```bash
N8N_DEFAULT_REQUEST_TIMEOUT=900000
EXECUTIONS_TIMEOUT=3600
EXECUTIONS_TIMEOUT_MAX=3600
```
3. Prefer **Docker network** (`http://report-service:8000/generate`) over ngrok when n8n and the report service run on the same host — fewer proxy timeouts.
4. Watch report-service logs: each `kaleido` export spawns Chrome briefly; very large datasets are capped by `histogram_params.max_periods` (default 8 activity periods).
### Histogram shows `01-01-1970` / wrong period ### Histogram shows `01-01-1970` / wrong period
Strike timestamps were parsed incorrectly when `timestamp` (epoch ms) was preferred over `captured` (ISO). Fixed in `report_service/adapter.py` — restart the report service after pulling updates. Strike timestamps were parsed incorrectly when `timestamp` (epoch ms) was preferred over `captured` (ISO). Fixed in `report_service/adapter.py` — restart the report service after pulling updates.
@ -208,7 +245,8 @@ Do not double-`JSON.stringify` the body when posting to `response_url`. Slack re
## Development notes ## Development notes
- Per-farm settings (`rings`, centroid, dates) come from the n8n payload via `apply_farm_config()`, not hardcoded in `src/config.py`. - Per-farm settings (`rings`, centroid, dates, `language`) come from the n8n payload via `apply_farm_config()`, not hardcoded in `src/config.py`.
- Set `customers.language` to `tr` for Turkish DOCX output; omit or use `en` for English.
- Prefer strike field **`captured`** (ISO) for `local_time`; numeric **`timestamp`** is supported as epoch milliseconds. - Prefer strike field **`captured`** (ISO) for `local_time`; numeric **`timestamp`** is supported as epoch milliseconds.
- HMAC signing for iklim API calls is implemented in n8n Code nodes (`Calculate Lightning Headers`, `Calculate Thunderstorm Headers`). - HMAC signing for iklim API calls is implemented in n8n Code nodes (`Calculate Lightning Headers`, `Calculate Thunderstorm Headers`).

View File

@ -12,6 +12,7 @@ from typing import Any
import pandas as pd import pandas as pd
from src.config import config from src.config import config
from src.reporting.strings import normalize_language
_STORM_ENVELOPE_KEYS = ("thunderstorms", "cells", "storms", "data", "items") _STORM_ENVELOPE_KEYS = ("thunderstorms", "cells", "storms", "data", "items")
@ -220,6 +221,8 @@ def apply_farm_config(payload: dict[str, Any]) -> None:
if end_label: if end_label:
config.analysis_end_date = end_label config.analysis_end_date = end_label
config.language = normalize_language(payload.get("language"))
def build_dataframes(payload: dict[str, Any]) -> tuple[pd.DataFrame, pd.DataFrame]: def build_dataframes(payload: dict[str, Any]) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Return (turbine_df, lightning_df) ready for `create_docx_report()`.""" """Return (turbine_df, lightning_df) ready for `create_docx_report()`."""

View File

@ -2,8 +2,11 @@
FastAPI microservice that wraps `create_docx_report()` for use by n8n. FastAPI microservice that wraps `create_docx_report()` for use by n8n.
Endpoints: Endpoints:
- GET /health liveness probe - GET /health liveness probe
- POST /generate accept the `Report: Build Payload` JSON and return a DOCX - POST /generate accept payload JSON and return a DOCX (sync)
- POST /generate/async start background job, return job_id immediately
- GET /generate/async/{job_id} poll job status
- GET /generate/async/{job_id}/download download completed DOCX
Run locally: Run locally:
uvicorn report_service.main:app --host 0.0.0.0 --port 8000 uvicorn report_service.main:app --host 0.0.0.0 --port 8000
@ -14,7 +17,11 @@ import hmac
import logging import logging
import os import os
import tempfile import tempfile
import threading
import uuid
from contextlib import contextmanager from contextlib import contextmanager
from dataclasses import dataclass
from enum import Enum
from typing import Any from typing import Any
from fastapi import Depends, FastAPI, Header, HTTPException, Request from fastapi import Depends, FastAPI, Header, HTTPException, Request
@ -37,6 +44,27 @@ app = FastAPI(title="Lightning Report Service", version="1.0.0")
DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
class JobStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETE = "complete"
FAILED = "failed"
@dataclass
class ReportJob:
status: JobStatus
filename: str | None = None
path: str | None = None
error: str | None = None
customer_name: str | None = None
n_strikes: int = 0
_jobs: dict[str, ReportJob] = {}
_jobs_lock = threading.Lock()
def require_report_token(x_report_token: str | None = Header(default=None)) -> None: def require_report_token(x_report_token: str | None = Header(default=None)) -> None:
""" """
Shared-secret gate for the public `/generate` endpoint. Shared-secret gate for the public `/generate` endpoint.
@ -112,13 +140,7 @@ def _content_disposition_attachment(filename: str) -> str:
return f'attachment; filename="{ascii_fn}"; filename*=UTF-8\'\'{encoded}' return f'attachment; filename="{ascii_fn}"; filename*=UTF-8\'\'{encoded}'
@app.get("/health") async def _read_payload(request: Request) -> dict[str, Any]:
def health() -> JSONResponse:
return JSONResponse({"ok": True, "service": "lightning-report", "version": app.version})
@app.post("/generate", dependencies=[Depends(require_report_token)])
async def generate(request: Request) -> Response:
try: try:
payload: dict[str, Any] = await request.json() payload: dict[str, Any] = await request.json()
except Exception as exc: except Exception as exc:
@ -126,28 +148,27 @@ async def generate(request: Request) -> Response:
if not isinstance(payload, dict): if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Request body must be a JSON object") raise HTTPException(status_code=400, detail="Request body must be a JSON object")
return payload
def _build_docx_file(payload: dict[str, Any]) -> tuple[str, str, int]:
customer_name = payload.get("customer_name") or "<unknown>" customer_name = payload.get("customer_name") or "<unknown>"
n_strikes = int(payload.get("n_strikes") or 0) n_strikes = int(payload.get("n_strikes") or 0)
logger.info( logger.info(
"Generating report for customer=%s n_strikes=%s n_turbines=%s", "Generating report for customer=%s language=%s n_strikes=%s n_turbines=%s",
customer_name, customer_name,
payload.get("language") or "en",
n_strikes, n_strikes,
len(payload.get("turbines") or []), len(payload.get("turbines") or []),
) )
try: apply_farm_config(payload)
apply_farm_config(payload) turbine_df, lightning_df = build_dataframes(payload)
turbine_df, lightning_df = build_dataframes(payload)
except ValueError as exc:
logger.warning("Payload validation failed: %s", exc)
raise HTTPException(status_code=422, detail=str(exc)) from exc
storm_records = normalize_storm_records(payload.get("storm_records")) or None storm_records = normalize_storm_records(payload.get("storm_records")) or None
if storm_records is not None: if storm_records is not None:
logger.info("Normalized %d storm record(s) for %s", len(storm_records), customer_name) logger.info("Normalized %d storm record(s) for %s", len(storm_records), customer_name)
filename = _build_filename(payload)
filename = _build_filename(payload)
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".docx") tmp_fd, tmp_path = tempfile.mkstemp(suffix=".docx")
os.close(tmp_fd) os.close(tmp_fd)
@ -160,18 +181,17 @@ async def generate(request: Request) -> Response:
storm_data_path=None, storm_data_path=None,
storm_data_records=storm_records, storm_data_records=storm_records,
) )
with open(tmp_path, "rb") as fh: except Exception:
data = fh.read()
except Exception as exc:
logger.exception("Report generation failed for %s", customer_name)
raise HTTPException(status_code=500, detail=f"Report generation failed: {exc}") from exc
finally:
try: try:
os.unlink(tmp_path) os.unlink(tmp_path)
except OSError: except OSError:
pass pass
raise
logger.info("Generated %s (%d bytes) for %s", filename, len(data), customer_name) return tmp_path, filename, n_strikes
def _docx_response(data: bytes, filename: str, customer_name: str, n_strikes: int) -> Response:
stem = filename[: -len(".docx")] if filename.lower().endswith(".docx") else filename stem = filename[: -len(".docx")] if filename.lower().endswith(".docx") else filename
ascii_filename = _ascii_attachment_filename_from_stem(stem) ascii_filename = _ascii_attachment_filename_from_stem(stem)
ascii_filename = "".join(ch for ch in ascii_filename if ord(ch) < 128) or "report.docx" ascii_filename = "".join(ch for ch in ascii_filename if ord(ch) < 128) or "report.docx"
@ -184,8 +204,129 @@ async def generate(request: Request) -> Response:
"X-Report-Strikes": str(n_strikes), "X-Report-Strikes": str(n_strikes),
} }
) )
return Response( return Response(content=data, media_type=DOCX_MIME, headers=hdrs)
content=data,
media_type=DOCX_MIME,
headers=hdrs, def _run_report_job(job_id: str, payload: dict[str, Any]) -> None:
) customer_name = payload.get("customer_name") or "<unknown>"
with _jobs_lock:
job = _jobs.get(job_id)
if job is None:
return
job.status = JobStatus.RUNNING
try:
tmp_path, filename, n_strikes = _build_docx_file(payload)
with _jobs_lock:
job = _jobs.get(job_id)
if job is None:
try:
os.unlink(tmp_path)
except OSError:
pass
return
job.status = JobStatus.COMPLETE
job.filename = filename
job.path = tmp_path
job.customer_name = str(customer_name)
job.n_strikes = n_strikes
logger.info("Async job %s completed: %s for %s", job_id, filename, customer_name)
except ValueError as exc:
logger.warning("Async job %s payload validation failed: %s", job_id, exc)
with _jobs_lock:
job = _jobs.get(job_id)
if job is not None:
job.status = JobStatus.FAILED
job.error = str(exc)
except Exception as exc:
logger.exception("Async job %s failed for %s", job_id, customer_name)
with _jobs_lock:
job = _jobs.get(job_id)
if job is not None:
job.status = JobStatus.FAILED
job.error = f"Report generation failed: {exc}"
def _get_job_or_404(job_id: str) -> ReportJob:
with _jobs_lock:
job = _jobs.get(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Report job not found")
return job
@app.get("/health")
def health() -> JSONResponse:
return JSONResponse({"ok": True, "service": "lightning-report", "version": app.version})
@app.post("/generate", dependencies=[Depends(require_report_token)])
async def generate(request: Request) -> Response:
payload = await _read_payload(request)
customer_name = payload.get("customer_name") or "<unknown>"
try:
tmp_path, filename, n_strikes = _build_docx_file(payload)
with open(tmp_path, "rb") as fh:
data = fh.read()
except ValueError as exc:
logger.warning("Payload validation failed: %s", exc)
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
logger.exception("Report generation failed for %s", customer_name)
raise HTTPException(status_code=500, detail=f"Report generation failed: {exc}") from exc
finally:
try:
os.unlink(tmp_path)
except (OSError, UnboundLocalError):
pass
logger.info("Generated %s (%d bytes) for %s", filename, len(data), customer_name)
return _docx_response(data, filename, str(customer_name), n_strikes)
@app.post("/generate/async", dependencies=[Depends(require_report_token)])
async def generate_async(request: Request) -> JSONResponse:
payload = await _read_payload(request)
job_id = str(uuid.uuid4())
with _jobs_lock:
_jobs[job_id] = ReportJob(status=JobStatus.PENDING, customer_name=str(payload.get("customer_name") or ""))
thread = threading.Thread(target=_run_report_job, args=(job_id, payload), daemon=True)
thread.start()
logger.info("Queued async report job %s for %s", job_id, payload.get("customer_name"))
return JSONResponse({"job_id": job_id, "status": JobStatus.PENDING.value})
@app.get("/generate/async/{job_id}", dependencies=[Depends(require_report_token)])
async def generate_async_status(job_id: str) -> JSONResponse:
job = _get_job_or_404(job_id)
body: dict[str, Any] = {"job_id": job_id, "status": job.status.value}
if job.status == JobStatus.COMPLETE and job.filename:
body["filename"] = job.filename
if job.status == JobStatus.FAILED and job.error:
body["error"] = job.error
return JSONResponse(body)
@app.get("/generate/async/{job_id}/download", dependencies=[Depends(require_report_token)])
async def generate_async_download(job_id: str) -> Response:
job = _get_job_or_404(job_id)
if job.status != JobStatus.COMPLETE or not job.path:
raise HTTPException(status_code=409, detail=f"Report job is not complete (status={job.status.value})")
try:
with open(job.path, "rb") as fh:
data = fh.read()
finally:
try:
os.unlink(job.path)
except OSError:
pass
with _jobs_lock:
_jobs.pop(job_id, None)
filename = job.filename or "report.docx"
customer_name = job.customer_name or "<unknown>"
logger.info("Downloaded async job %s: %s (%d bytes)", job_id, filename, len(data))
return _docx_response(data, filename, customer_name, job.n_strikes)

View File

@ -6,6 +6,7 @@ import plotly.graph_objects as go
from plotly.subplots import make_subplots from plotly.subplots import make_subplots
from src.analysis.geospatial import haversine_distance, haversine_distance_vectorized from src.analysis.geospatial import haversine_distance, haversine_distance_vectorized
from src.config import config from src.config import config
from src.reporting.strings import get_report_language, get_strings
from src.utils import get_analysis_radius_m from src.utils import get_analysis_radius_m
import logging import logging
@ -142,7 +143,8 @@ def find_peak_sub_periods(period_data, window_minutes=3):
return peak_periods return peak_periods
def _build_histogram_figure_for_periods(periods_chunk, max_distance_km): def _build_histogram_figure_for_periods(periods_chunk, max_distance_km, lang=None):
s = get_strings(lang or get_report_language())
""" """
Build a Plotly figure for a subset of activity periods. Build a Plotly figure for a subset of activity periods.
@ -255,7 +257,7 @@ def _build_histogram_figure_for_periods(periods_chunk, max_distance_km):
# Update axes for this subplot # Update axes for this subplot
fig.update_xaxes( fig.update_xaxes(
title_text="Minutes from start", title_text=s.hist_minutes_from_start,
title_standoff=6, title_standoff=6,
row=row, row=row,
col=col, col=col,
@ -265,7 +267,7 @@ def _build_histogram_figure_for_periods(periods_chunk, max_distance_km):
# Y-axis labels only on leftmost column # Y-axis labels only on leftmost column
if col == 1: if col == 1:
fig.update_yaxes( fig.update_yaxes(
title_text="Lightning Count", title_text=s.hist_lightning_count,
row=row, row=row,
col=col, col=col,
tickfont=dict(size=18), tickfont=dict(size=18),
@ -336,6 +338,10 @@ def create_lightning_histogram_pages(lightning_df, centroid_lat, centroid_lng):
if len(periods) == 0: if len(periods) == 0:
return [] return []
max_periods = config.histogram_params.get('max_periods')
if isinstance(max_periods, int) and max_periods > 0:
periods = periods[:max_periods]
# Get max periods per figure from config # Get max periods per figure from config
max_periods_per_figure = config.histogram_params.get('max_periods_per_figure', 6) max_periods_per_figure = config.histogram_params.get('max_periods_per_figure', 6)
@ -346,7 +352,7 @@ def create_lightning_histogram_pages(lightning_df, centroid_lat, centroid_lng):
figures = [] figures = []
for i in range(0, len(periods), max_periods_per_figure): for i in range(0, len(periods), max_periods_per_figure):
chunk = periods[i:i + max_periods_per_figure] chunk = periods[i:i + max_periods_per_figure]
fig = _build_histogram_figure_for_periods(chunk, max_distance_km) fig = _build_histogram_figure_for_periods(chunk, max_distance_km, get_report_language())
if fig: if fig:
figures.append(fig) figures.append(fig)

View File

@ -23,6 +23,7 @@ class Config:
centroid_lat: Optional[float] = None centroid_lat: Optional[float] = None
centroid_lon: Optional[float] = None centroid_lon: Optional[float] = None
analysis_boundary_m: Optional[int] = None analysis_boundary_m: Optional[int] = None
language: Optional[str] = None
# Lightning data source configuration # Lightning data source configuration
# By default, lightning data is expected from API (JSON output). # By default, lightning data is expected from API (JSON output).

View File

@ -7,6 +7,9 @@ from datetime import datetime
from typing import Any, Iterable from typing import Any, Iterable
import pandas as pd import pandas as pd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from docx import Document from docx import Document
from docx.enum.section import WD_ORIENT from docx.enum.section import WD_ORIENT
@ -44,6 +47,7 @@ from src.visualization.storm_cells import (
load_storm_data_from_json, load_storm_data_from_json,
) )
from src.reporting.gemini_commentary import generate_gemini_paragraph from src.reporting.gemini_commentary import generate_gemini_paragraph
from src.reporting.strings import get_report_language, get_strings
from src.utils import get_risk_definition_by_fixed_intervals from src.utils import get_risk_definition_by_fixed_intervals
@ -224,22 +228,23 @@ def _ring_color_for_label(label: str) -> str | None:
return None return None
def _add_risk_color_legend(doc: Document, size_pt: int = 10) -> None: def _add_risk_color_legend(doc: Document, size_pt: int = 10, lang: str | None = None) -> None:
from src.utils import get_risk_definition_by_fixed_intervals, get_turbine_color_by_fixed_intervals from src.utils import get_turbine_color_by_fixed_intervals
s = get_strings(lang or get_report_language())
legend_items: list[tuple[str, str]] = [ legend_items: list[tuple[str, str]] = [
("Very Low Risk (< 0.1)", get_turbine_color_by_fixed_intervals(0.05)), (s.risk_legend_very_low, get_turbine_color_by_fixed_intervals(0.05)),
("Low Risk (0.1 - 0.2)", get_turbine_color_by_fixed_intervals(0.15)), (s.risk_legend_low, get_turbine_color_by_fixed_intervals(0.15)),
("Med-Low Risk (0.2 - 0.4)", get_turbine_color_by_fixed_intervals(0.30)), (s.risk_legend_med_low, get_turbine_color_by_fixed_intervals(0.30)),
("Medium Risk (0.4 - 0.6)", get_turbine_color_by_fixed_intervals(0.50)), (s.risk_legend_medium, get_turbine_color_by_fixed_intervals(0.50)),
("Med-High Risk (0.6 - 0.8)", get_turbine_color_by_fixed_intervals(0.70)), (s.risk_legend_med_high, get_turbine_color_by_fixed_intervals(0.70)),
("High Risk (0.8 - 1.0)", get_turbine_color_by_fixed_intervals(0.90)), (s.risk_legend_high, get_turbine_color_by_fixed_intervals(0.90)),
("Very High Risk (1.0 - 1.2)", get_turbine_color_by_fixed_intervals(1.10)), (s.risk_legend_very_high, get_turbine_color_by_fixed_intervals(1.10)),
("Critical Risk (1.2 - 1.4)", get_turbine_color_by_fixed_intervals(1.30)), (s.risk_legend_critical, get_turbine_color_by_fixed_intervals(1.30)),
("Maximum Risk (≥ 1.4)", get_turbine_color_by_fixed_intervals(1.50)), (s.risk_legend_maximum, get_turbine_color_by_fixed_intervals(1.50)),
] ]
_add_paragraph(doc, "Risk Color Legend (Log Risk):", size_pt=size_pt, bold=True) _add_paragraph(doc, s.risk_color_legend, size_pt=size_pt, bold=True)
for label, color in legend_items: for label, color in legend_items:
p = doc.add_paragraph() p = doc.add_paragraph()
c = str(color).strip() c = str(color).strip()
@ -441,6 +446,8 @@ def create_docx_report(
) -> None: ) -> None:
start_date = config.analysis_start_date start_date = config.analysis_start_date
end_date = config.analysis_end_date end_date = config.analysis_end_date
lang = get_report_language()
s = get_strings(lang)
doc = Document() doc = Document()
_set_document_page_setup(doc, DocxPageConfig()) _set_document_page_setup(doc, DocxPageConfig())
@ -449,15 +456,15 @@ def create_docx_report(
# Cover # Cover
_add_cover_logo_centered(doc) _add_cover_logo_centered(doc)
_add_title(doc, "Lightning Activity Report for", size_pt=22, align=WD_ALIGN_PARAGRAPH.CENTER) _add_title(doc, s.cover_title, size_pt=22, align=WD_ALIGN_PARAGRAPH.CENTER)
_add_title(doc, f"{config.wind_farm_name or ''}", size_pt=22, align=WD_ALIGN_PARAGRAPH.CENTER) _add_title(doc, f"{config.wind_farm_name or ''}", size_pt=22, align=WD_ALIGN_PARAGRAPH.CENTER)
period_has_time = (start_date and " " in str(start_date) and ":" in str(start_date)) or (end_date and " " in str(end_date) and ":" in str(end_date)) period_has_time = (start_date and " " in str(start_date) and ":" in str(start_date)) or (end_date and " " in str(end_date) and ":" in str(end_date))
if period_has_time: if period_has_time:
utc_label = get_utc_offset_label(getattr(config, "timezone", None)) utc_label = get_utc_offset_label(getattr(config, "timezone", None))
period_label = f"Analyzed Period ({utc_label}):" if utc_label else "Analyzed Period (local time):" period_label = f"{s.analyzed_period[:-1]} ({utc_label}):" if utc_label else f"{s.analyzed_period_local}"
else: else:
period_label = "Analyzed Period:" period_label = s.analyzed_period
_add_paragraph(doc, period_label, size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER, bold=True) _add_paragraph(doc, period_label, size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER, bold=True)
_add_paragraph(doc, f"{start_date or ''} - {end_date or ''}", size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER) _add_paragraph(doc, f"{start_date or ''} - {end_date or ''}", size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER)
@ -472,13 +479,13 @@ def create_docx_report(
centroid_lng = float(config.centroid_lon) centroid_lng = float(config.centroid_lon)
else: else:
centroid_lng = float(turbine_df["lng"].mean()) if len(turbine_df) > 0 else 0.0 centroid_lng = float(turbine_df["lng"].mean()) if len(turbine_df) > 0 else 0.0
_add_paragraph(doc, "Centroid Coordinates:", size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER, bold=True) _add_paragraph(doc, s.centroid_coordinates, size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER, bold=True)
_add_paragraph(doc, f"Latitude: {centroid_lat:.5f}", size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER) _add_paragraph(doc, f"{s.latitude}: {centroid_lat:.5f}", size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER)
_add_paragraph(doc, f"Longitude: {centroid_lng:.5f}", size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER) _add_paragraph(doc, f"{s.longitude}: {centroid_lng:.5f}", size_pt=12, align=WD_ALIGN_PARAGRAPH.CENTER)
_add_paragraph(doc, f"Report Generated: {format_datetime_ddmmyyyy_hhmm(now_in_timezone(getattr(config, 'timezone', None)))}", size_pt=11, align=WD_ALIGN_PARAGRAPH.CENTER) _add_paragraph(doc, f"{s.report_generated}: {format_datetime_ddmmyyyy_hhmm(now_in_timezone(getattr(config, 'timezone', None)))}", size_pt=11, align=WD_ALIGN_PARAGRAPH.CENTER)
_add_paragraph(doc, "This report provides comprehensive lightning and storm activity analysis for wind farm", size_pt=10, align=WD_ALIGN_PARAGRAPH.CENTER) _add_paragraph(doc, s.cover_intro_1, size_pt=10, align=WD_ALIGN_PARAGRAPH.CENTER)
_add_paragraph(doc, "operations and safety assessment. Each section includes detailed explanations to help", size_pt=10, align=WD_ALIGN_PARAGRAPH.CENTER) _add_paragraph(doc, s.cover_intro_2, size_pt=10, align=WD_ALIGN_PARAGRAPH.CENTER)
_add_paragraph(doc, "you understand the data and make informed decisions about turbine safety", size_pt=10, align=WD_ALIGN_PARAGRAPH.CENTER) _add_paragraph(doc, s.cover_intro_3, size_pt=10, align=WD_ALIGN_PARAGRAPH.CENTER)
doc.add_page_break() doc.add_page_break()
_add_header_logo(doc) _add_header_logo(doc)
@ -513,9 +520,9 @@ def create_docx_report(
histogram_figs = create_lightning_histogram_pages(lightning_df, centroid_lat, centroid_lng) histogram_figs = create_lightning_histogram_pages(lightning_df, centroid_lat, centroid_lng)
# Turbine information # Turbine information
_add_title(doc, "Turbine Information", size_pt=16, align=WD_ALIGN_PARAGRAPH.LEFT) _add_title(doc, s.turbine_information, size_pt=16, align=WD_ALIGN_PARAGRAPH.LEFT)
_add_paragraph(doc, "This table contains detailed information about all turbines in the wind farm.", size_pt=10) _add_paragraph(doc, s.turbine_information_desc, size_pt=10)
_add_table(doc, build_turbine_information_table_data(turbine_df)) _add_table(doc, build_turbine_information_table_data(turbine_df, lang))
# Gemini commentary (single API call per report run; falls back deterministically if Gemini is unavailable) # Gemini commentary (single API call per report run; falls back deterministically if Gemini is unavailable)
analysis_radius_km = float(get_analysis_radius_m()) / 1000.0 if get_analysis_radius_m() > 0 else float(max(config.distance_rings) / 1000.0) analysis_radius_km = float(get_analysis_radius_m()) / 1000.0 if get_analysis_radius_m() > 0 else float(max(config.distance_rings) / 1000.0)
@ -578,9 +585,9 @@ def create_docx_report(
top_turbine = turbine_df.loc[top_idx] top_turbine = turbine_df.loc[top_idx]
top_turbine_name = str(top_turbine.get("name", "N/A")) top_turbine_name = str(top_turbine.get("name", "N/A"))
top_turbine_risk_log = float(top_turbine.get("risk_log", risk_log_max)) top_turbine_risk_log = float(top_turbine.get("risk_log", risk_log_max))
max_risk_definition = get_risk_definition_by_fixed_intervals(risk_log_max) max_risk_definition = get_risk_definition_by_fixed_intervals(risk_log_max, lang)
for rl in turbine_df["risk_log"].tolist(): for rl in turbine_df["risk_log"].tolist():
defn = get_risk_definition_by_fixed_intervals(float(rl)) defn = get_risk_definition_by_fixed_intervals(float(rl), lang)
turbine_risk_counts[defn] = turbine_risk_counts.get(defn, 0) + 1 turbine_risk_counts[defn] = turbine_risk_counts.get(defn, 0) + 1
storm_summary = create_storm_cells_summary(storm_data) if storm_data else None storm_summary = create_storm_cells_summary(storm_data) if storm_data else None
@ -645,14 +652,14 @@ def create_docx_report(
commentary_text = generate_gemini_paragraph(commentary_context) commentary_text = generate_gemini_paragraph(commentary_context)
commentary_text = commentary_text.strip() commentary_text = commentary_text.strip()
# Defensive cleanup: sometimes models may prepend a heading/label. # Defensive cleanup: sometimes models may prepend a heading/label.
for prefix in ("Gemini Commentary:", "Gemini commentary:", "Commentary:", "Commentary"): for prefix in s.commentary_prefixes:
if commentary_text.startswith(prefix): if commentary_text.startswith(prefix):
commentary_text = commentary_text[len(prefix):].strip() commentary_text = commentary_text[len(prefix):].strip()
break break
# Keep the commentary close to the Turbine Information table (same page if possible). # Keep the commentary close to the Turbine Information table (same page if possible).
doc.add_paragraph("") doc.add_paragraph("")
doc.add_paragraph("") doc.add_paragraph("")
_add_title(doc, "Report Summary", size_pt=14, bold=True, align=WD_ALIGN_PARAGRAPH.LEFT) _add_title(doc, s.report_summary, size_pt=14, bold=True, align=WD_ALIGN_PARAGRAPH.LEFT)
# DOCX line spacing for the Gemini commentary paragraph. # DOCX line spacing for the Gemini commentary paragraph.
p = doc.add_paragraph() p = doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT p.alignment = WD_ALIGN_PARAGRAPH.LEFT
@ -666,7 +673,7 @@ def create_docx_report(
centroid_row = pd.Series({ centroid_row = pd.Series({
"lat": centroid_lat, "lat": centroid_lat,
"lng": centroid_lng, "lng": centroid_lng,
"name": config.wind_farm_name or "Wind Farm", "name": config.wind_farm_name or s.wind_farm_default,
}) })
pre = precompute_distances_and_rings(centroid_lat, centroid_lng, lightning_df, config.distance_rings) pre = precompute_distances_and_rings(centroid_lat, centroid_lng, lightning_df, config.distance_rings)
type_values = lightning_df.get("p_type", pd.Series(dtype=str)).astype(str) type_values = lightning_df.get("p_type", pd.Series(dtype=str)).astype(str)
@ -675,14 +682,14 @@ def create_docx_report(
has_ic = bool(((type_values != "0") & mask_within).any()) has_ic = bool(((type_values != "0") & mask_within).any())
if has_cg: if has_cg:
_add_title(doc, "Cloud-to-Ground Lightnings", size_pt=14) _add_title(doc, s.cloud_to_ground_lightnings, size_pt=14)
if start_date and end_date: if start_date and end_date:
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10)
cg_fig = plot_cloud_to_ground_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre) 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) _add_image_from_bytes(doc, cg_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width)
doc.add_page_break() doc.add_page_break()
_add_title(doc, "Cloud-to-Ground — Current vs Distance", size_pt=14) _add_title(doc, s.cloud_to_ground_current_vs_distance, size_pt=14)
if start_date and end_date: if start_date and end_date:
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10)
cg_chart = _build_current_vs_distance_chart( cg_chart = _build_current_vs_distance_chart(
@ -690,23 +697,24 @@ def create_docx_report(
pre["dists_km"], pre["dists_km"],
pre["mask_within"], pre["mask_within"],
"cg", "cg",
"Cloud-to-Ground Lightning — Current vs Distance from Centroid", s.cg_chart_title,
900, 900,
650, 650,
lang=lang,
) )
if cg_chart is not None: if cg_chart is not None:
_add_image_from_bytes(doc, cg_chart.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) _add_image_from_bytes(doc, cg_chart.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width)
doc.add_page_break() doc.add_page_break()
if has_ic: if has_ic:
_add_title(doc, "Intercloud Lightnings", size_pt=14) _add_title(doc, s.intercloud_lightnings, size_pt=14)
if start_date and end_date: if start_date and end_date:
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10)
ic_fig = plot_intercloud_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre) 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) _add_image_from_bytes(doc, ic_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width)
doc.add_page_break() doc.add_page_break()
_add_title(doc, "Intercloud — Current vs Distance", size_pt=14) _add_title(doc, s.intercloud_current_vs_distance, size_pt=14)
if start_date and end_date: if start_date and end_date:
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10) _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10)
ic_chart = _build_current_vs_distance_chart( ic_chart = _build_current_vs_distance_chart(
@ -714,57 +722,94 @@ def create_docx_report(
pre["dists_km"], pre["dists_km"],
pre["mask_within"], pre["mask_within"],
"ic", "ic",
"Intercloud Lightning — Current vs Distance from Centroid", s.ic_chart_title,
900, 900,
650, 650,
lang=lang,
) )
if ic_chart is not None: if ic_chart is not None:
_add_image_from_bytes(doc, ic_chart.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width) _add_image_from_bytes(doc, ic_chart.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width)
doc.add_page_break() doc.add_page_break()
risk_table_data, risk_row_colors = build_risk_table_data(turbine_df) risk_table_data, risk_row_colors = build_risk_table_data(turbine_df, lang)
if risk_table_data and len(risk_table_data) > 1: if risk_table_data and len(risk_table_data) > 1:
_add_title(doc, "Turbine Risk Assessment", size_pt=14) _add_title(doc, s.turbine_risk_assessment, size_pt=14)
_add_table(doc, risk_table_data, row_colors=risk_row_colors) _add_table(doc, risk_table_data, row_colors=risk_row_colors)
doc.add_page_break() doc.add_page_break()
# Daily breakdown # Daily breakdown
_add_title(doc, "Lightning Breakdown by Distance Rings", size_pt=14) _add_title(doc, s.lightning_breakdown_rings, size_pt=14)
if start_date and end_date: if start_date and end_date:
_add_paragraph(doc, f"Period: {start_date} - {end_date} (Total: {stats.get('total_events', 0)} lightning events)", size_pt=10, bold=True) _add_paragraph(
doc,
s.period_total_events.format(
start=start_date,
end=end_date,
total=stats.get("total_events", 0),
),
size_pt=10,
bold=True,
)
closest_km = (config.distance_rings[0] / 1000.0) if getattr(config, "distance_rings", None) else 1.0 closest_km = (config.distance_rings[0] / 1000.0) if getattr(config, "distance_rings", None) else 1.0
analysis_radius_km = get_analysis_radius_m() / 1000.0 if get_analysis_radius_m() > 0 else closest_km analysis_radius_km = get_analysis_radius_m() / 1000.0 if get_analysis_radius_m() > 0 else closest_km
_add_paragraph(doc, f"Area covered within {analysis_radius_km:.1f} km radius: {stats.get('area_km2', 0):.1f} km²", size_pt=10)
_add_paragraph(doc, f"Total lightnings within {analysis_radius_km:.1f} km radius: {stats.get('total_events', 0)} events", size_pt=10)
_add_paragraph(doc, f"Total lightning density: {stats.get('total_lightning_per_km2', 0):.3f} events/km²", size_pt=10)
_add_paragraph( _add_paragraph(
doc, doc,
f"(Calculation: {stats.get('total_events', 0)} total lightnings / {stats.get('area_km2', 0):.1f} km² area)", s.area_covered.format(radius=analysis_radius_km, area=stats.get("area_km2", 0)),
size_pt=10,
)
_add_paragraph(
doc,
s.total_lightnings_radius.format(radius=analysis_radius_km, total=stats.get("total_events", 0)),
size_pt=10,
)
_add_paragraph(
doc,
s.total_density.format(density=stats.get("total_lightning_per_km2", 0)),
size_pt=10,
)
_add_paragraph(
doc,
s.density_calc.format(total=stats.get("total_events", 0), area=stats.get("area_km2", 0)),
size_pt=9, size_pt=9,
) )
period_days = float(stats.get("period_days", 0.0) or 0.0) period_days = float(stats.get("period_days", 0.0) or 0.0)
if period_days >= 1.0: if period_days >= 1.0:
_add_paragraph(doc, f"Daily equivalent density: {stats.get('daily_lightning_per_km2', 0):.3f} events/km²/day", size_pt=10)
if period_days == 1.0:
days_label = "1 day in the period"
elif period_days != int(period_days):
days_label = f"{period_days:.1f} days in the period"
else:
days_label = f"{int(period_days)} days in the period"
_add_paragraph( _add_paragraph(
doc, doc,
f"(Calculation: {stats.get('total_events', 0)} total lightnings / {stats.get('area_km2', 0):.1f} km² area / {days_label})", s.daily_density.format(density=stats.get("daily_lightning_per_km2", 0)),
size_pt=10,
)
if period_days == 1.0:
days_label = s.day_in_period
elif period_days != int(period_days):
days_label = s.days_in_period.format(days=f"{period_days:.1f}")
else:
days_label = s.days_in_period.format(days=int(period_days))
_add_paragraph(
doc,
s.daily_density_calc.format(
total=stats.get("total_events", 0),
area=stats.get("area_km2", 0),
days_label=days_label,
),
size_pt=9, size_pt=9,
) )
_add_paragraph(doc, "This section shows lightning events over the analyzed period by distance from turbines.", size_pt=10) _add_paragraph(doc, s.ring_section_intro, size_pt=10)
_add_paragraph(doc, f"Higher counts in closer rings (0-{closest_km:.1f}km) indicate elevated risk to turbine operations.", size_pt=10) _add_paragraph(doc, s.ring_closer_risk.format(closest=closest_km), size_pt=10)
ring_items: list[str] = [] ring_items: list[str] = []
for ring_name, ring_data in sorted((stats.get("lightning_by_distance_rings") or {}).items()): for ring_name, ring_data in sorted((stats.get("lightning_by_distance_rings") or {}).items()):
if ring_data.get("total", 0) > 0: if ring_data.get("total", 0) > 0:
ring_items.append(f"{ring_name}: {ring_data['total']} total ({ring_data['cloud_to_ground']} cloud-to-ground, {ring_data['intercloud']} intercloud)") ring_items.append(
s.ring_item.format(
ring=ring_name,
total=ring_data["total"],
cg=ring_data["cloud_to_ground"],
ic=ring_data["intercloud"],
)
)
if ring_items: if ring_items:
for item in ring_items: for item in ring_items:
_add_ring_bullet(doc, item, _ring_color_for_label(item), size_pt=10) _add_ring_bullet(doc, item, _ring_color_for_label(item), size_pt=10)
@ -773,65 +818,57 @@ def create_docx_report(
# Histograms # Histograms
for idx, fig in enumerate(histogram_figs): for idx, fig in enumerate(histogram_figs):
if idx == 0: if idx == 0:
_add_title(doc, "Frequent Lightning Activity Report", size_pt=14) _add_title(doc, s.frequent_lightning_report, size_pt=14)
if start_date and end_date: if start_date and end_date:
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10, bold=True) _add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10, bold=True)
analysis_radius_km = get_analysis_radius_m() / 1000.0 if get_analysis_radius_m() > 0 else closest_km analysis_radius_km = get_analysis_radius_m() / 1000.0 if get_analysis_radius_m() > 0 else closest_km
_add_paragraph(doc, "Lightning Activity Overview:", size_pt=11, bold=True) _add_paragraph(doc, s.lightning_activity_overview, size_pt=11, bold=True)
_add_paragraph( _add_paragraph(
doc, doc,
f"The chart below shows lightning activity patterns over time within {analysis_radius_km:.1f} km radius, helping identify high-risk periods and concentrated storm events. Peaks indicate intense lightning activity requiring attention.", s.histogram_overview.format(radius=analysis_radius_km),
size_pt=10,
)
_add_paragraph(
doc,
'For detailed information go to the "Frequent Lightning Activity Period Detection Algorithm" section in the appendix.',
size_pt=10, size_pt=10,
) )
_add_paragraph(doc, s.histogram_appendix_ref, size_pt=10)
else: else:
_add_title(doc, "Frequent Lightning Activity Periods", size_pt=14) _add_title(doc, s.frequent_lightning_periods, size_pt=14)
_add_image_from_bytes(doc, fig.to_image(format="png", width=1400, height=900, scale=2, engine="kaleido"), content_width) _add_image_from_bytes(doc, fig.to_image(format="png", width=1400, height=900, scale=2, engine="kaleido"), content_width)
doc.add_page_break() doc.add_page_break()
# Storm cells (summary per day) # Storm cells (summary per day)
if storm_data: if storm_data:
_add_title(doc, "Storm Cells Analysis Summary", size_pt=16) _add_title(doc, s.storm_cells_analysis_summary, size_pt=16)
_add_paragraph(doc, f"{start_date or ''} - {end_date or ''}", size_pt=12, align=WD_ALIGN_PARAGRAPH.LEFT, bold=True) _add_paragraph(doc, f"{start_date or ''} - {end_date or ''}", size_pt=12, align=WD_ALIGN_PARAGRAPH.LEFT, bold=True)
_add_paragraph(doc, "Storm Cells Overview:", size_pt=11, bold=True) _add_paragraph(doc, s.storm_cells_overview, size_pt=11, bold=True)
_add_paragraph( _add_paragraph(doc, s.storm_cells_overview_1, size_pt=10)
doc, _add_paragraph(doc, s.storm_cells_overview_2, size_pt=10)
"The following pages show storm cell boundaries organized by day during the analysis period.",
size_pt=10,
)
_add_paragraph(
doc,
"Each cell represents a storm system with defined boundaries and storm severity levels.",
size_pt=10,
)
summary = create_storm_cells_summary(storm_data) summary = create_storm_cells_summary(storm_data)
_add_paragraph(doc, "Storm Cells Summary:", size_pt=11, bold=True) _add_paragraph(doc, s.storm_cells_summary, size_pt=11, bold=True)
_add_paragraph(doc, f"Total storm cells: {summary.get('total_cells', 0)}", size_pt=10) _add_paragraph(doc, s.total_storm_cells.format(count=summary.get("total_cells", 0)), size_pt=10)
severity_counts: dict[str, int] = summary.get("severity_counts", {}) or {} severity_counts: dict[str, int] = summary.get("severity_counts", {}) or {}
_add_paragraph(doc, "Severity breakdown:", size_pt=10, bold=True) _add_paragraph(doc, s.severity_breakdown, size_pt=10, bold=True)
_add_bullets(doc, [f"{severity}: {count} cells" for severity, count in severity_counts.items()], size_pt=10) _add_bullets(
doc,
[s.severity_cells.format(severity=severity, count=count) for severity, count in severity_counts.items()],
size_pt=10,
)
_add_paragraph(doc, f"Average direction: {summary.get('avg_direction', 0):.1f}°", size_pt=10) _add_paragraph(doc, s.avg_direction.format(value=summary.get("avg_direction", 0)), size_pt=10)
_add_paragraph(doc, f"Average speed: {summary.get('avg_speed', 0):.1f} km/h", 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 {} daily_breakdown: dict[str, int] = summary.get("daily_breakdown", {}) or {}
_add_paragraph(doc, "Daily Storm Breakdown:", size_pt=10, bold=True) _add_paragraph(doc, s.daily_storm_breakdown, size_pt=10, bold=True)
sorted_days = sorted(daily_breakdown.keys()) sorted_days = sorted(daily_breakdown.keys())
daily_items: list[str] = [] daily_items: list[str] = []
for day in sorted_days[:10]: for day in sorted_days[:10]:
daily_items.append(f"{day}: {daily_breakdown.get(day, 0)} storm cells") daily_items.append(s.daily_storm_item.format(day=day, count=daily_breakdown.get(day, 0)))
if len(sorted_days) > 10: if len(sorted_days) > 10:
daily_items.append(f"... and {len(sorted_days) - 10} more days") daily_items.append(s.more_days.format(n=len(sorted_days) - 10))
if daily_items: if daily_items:
_add_bullets(doc, daily_items, size_pt=10) _add_bullets(doc, daily_items, size_pt=10)
_add_paragraph(doc, "Complete Storm Cells List:", size_pt=10, bold=True) _add_paragraph(doc, s.complete_storm_list, size_pt=10, bold=True)
def _storm_effective_time(storm: dict[str, Any]) -> datetime: def _storm_effective_time(storm: dict[str, Any]) -> datetime:
raw = storm.get("effective_time") or storm.get("creation_time") or storm.get("expire_time") or "" raw = storm.get("effective_time") or storm.get("creation_time") or storm.get("expire_time") or ""
@ -845,7 +882,9 @@ def create_docx_report(
return datetime.min return datetime.min
sorted_storms = sorted(storm_data, key=_storm_effective_time) sorted_storms = sorted(storm_data, key=_storm_effective_time)
table_data: list[list[str]] = [["No.", "Severity", "Effective Time", "Expire Time"]] table_data: list[list[str]] = [
[s.col_no.replace("#", "No."), s.col_severity, s.col_effective_time, s.col_expire_time]
]
row_colors: list[str] = ["lightgrey"] row_colors: list[str] = ["lightgrey"]
for i, storm in enumerate(sorted_storms, start=1): for i, storm in enumerate(sorted_storms, start=1):
report_tz = getattr(config, "timezone", None) report_tz = getattr(config, "timezone", None)
@ -858,12 +897,12 @@ def create_docx_report(
table_data.append( table_data.append(
[ [
str(i), str(i),
str(storm.get("lightning_severity", "Unknown")), str(storm.get("lightning_severity", s.unknown)),
effective_formatted, effective_formatted,
expire_formatted, expire_formatted,
] ]
) )
severity = str(storm.get("lightning_severity", "Unknown") or "Unknown").strip().lower() severity = str(storm.get("lightning_severity", s.unknown) or s.unknown).strip().lower()
if severity == "high": if severity == "high":
row_colors.append("purple") row_colors.append("purple")
elif severity == "medium": elif severity == "medium":
@ -885,9 +924,9 @@ def create_docx_report(
doc.add_page_break() doc.add_page_break()
_add_title(doc, "Storm Cells", size_pt=14) _add_title(doc, s.storm_cells, size_pt=14)
_add_paragraph(doc, "Daily storm cells visualization.", size_pt=10) _add_paragraph(doc, s.storm_cells_daily_viz, size_pt=10)
storm_fig = create_storm_cells_map(storm_data, turbine_df) storm_fig = create_storm_cells_map(storm_data, turbine_df, lang=lang)
_add_image_from_bytes( _add_image_from_bytes(
doc, doc,
storm_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), storm_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"),
@ -896,18 +935,10 @@ def create_docx_report(
doc.add_page_break() doc.add_page_break()
# Farm-wide lightning event table, anchored at the n8n-supplied centroid. # Farm-wide lightning event table, anchored at the n8n-supplied centroid.
_add_title(doc, "Detailed Lightning Event Data", size_pt=14) _add_title(doc, s.detailed_lightning_data, size_pt=14)
table_data, row_colors = build_lightning_event_table_data(centroid_lat, centroid_lng, lightning_df) table_data, row_colors = build_lightning_event_table_data(centroid_lat, centroid_lng, lightning_df, lang)
if table_data and table_data[0]: if table_data and table_data[0]:
table_data[0] = [ table_data[0] = [str(h).replace(" ", "\u00A0", 1) if " " in str(h) else str(h) for h in table_data[0]]
str(h)
.replace("Time (Local)", "Time\u00A0(Local)")
.replace("Current (amps)", "Current\u00A0(amps)")
.replace("Height (m)", "Height\u00A0(m)")
.replace("Lightning Type", "Lightning\u00A0Type")
.replace("Proximity (km)", "Proximity\u00A0(km)")
for h in table_data[0]
]
available_width_cm = float(content_width) * 2.54 available_width_cm = float(content_width) * 2.54
min_col_widths_cm = [1, 3.2, 1.6, 1.6, 1.9, 1.4, 2.0, 1.8] min_col_widths_cm = [1, 3.2, 1.6, 1.6, 1.9, 1.4, 2.0, 1.8]
font_pt, col_widths_cm = _fit_one_line_table_layout( font_pt, col_widths_cm = _fit_one_line_table_layout(
@ -928,134 +959,106 @@ def create_docx_report(
doc.add_page_break() doc.add_page_break()
# Appendix # Appendix
_add_title(doc, "Appendix", size_pt=16) _add_title(doc, s.appendix, size_pt=16)
# 1. Risk Calculation Method _add_title(doc, s.risk_calc_method, size_pt=14)
_add_title(doc, "1. Risk Calculation Method", size_pt=14) _add_paragraph(doc, s.how_risk_determined, size_pt=11, bold=True)
_add_paragraph(doc, "How Risk Scores Are Determined:", size_pt=11, bold=True) _add_paragraph(doc, s.risk_exposure_1, size_pt=10)
_add_paragraph(doc, "A turbines risk score represents its exposure to cloud-to-ground lightning during the analysis period.", size_pt=10) _add_paragraph(doc, s.risk_exposure_2, size_pt=10)
_add_paragraph(doc, "Each lightning strike contributes some risk based on distance to the turbine and the strikes current magnitude, and these contributions are summed.", size_pt=10)
_add_math_formula( _add_math_formula(
doc, doc,
r"r(d, I) = P_{0} \times \left(1 + \mathrm{current\_weight}\,\frac{|I|}{10000}\right) \times e^{-\alpha\,d}", r"r(d, I) = P_{0} \times \left(1 + \mathrm{current\_weight}\,\frac{|I|}{10000}\right) \times e^{-\alpha\,d}",
width_inches=min(content_width, 6.5), width_inches=min(content_width, 6.5),
) )
_add_paragraph(doc, "P0 (base factor): baseline scaling applied to every strikes contribution", size_pt=10) _add_paragraph(doc, s.p0_desc, size_pt=10)
_add_paragraph(doc, "current_weight: controls how strongly current magnitude increases risk (larger = more weight on |I|)", size_pt=10) _add_paragraph(doc, s.current_weight_desc, size_pt=10)
_add_paragraph(doc, "Distance (d): turbine-to-strike distance in kilometers", size_pt=10) _add_paragraph(doc, s.distance_desc, size_pt=10)
_add_paragraph(doc, "Current (|I|): absolute current magnitude |I| in amperes", size_pt=10) _add_paragraph(doc, s.current_desc, size_pt=10)
_add_paragraph(doc, "α (distance decay factor): controls how risk decays with distance (smaller = slower decay)", size_pt=10) _add_paragraph(doc, s.alpha_desc, size_pt=10)
_add_bullets( _add_bullets(
doc, doc,
[ [s.included_events, s.per_strike_contribution],
"Included events: only cloud-to-ground lightning (p_type = 0)",
"Per-strike contribution: increases with |I| and decreases exponentially with distance",
],
size_pt=10, size_pt=10,
) )
_add_paragraph(doc, "Turbine's risk score: sum of per-strike contributions for all included strikes (typically within the outermost distance ring)", size_pt=10) _add_paragraph(doc, s.turbine_risk_sum, size_pt=10)
_add_math_formula( _add_math_formula(
doc, doc,
r"\mathrm{turbine\_risk\_score}(t) = \sum_{j \in \mathcal{S}} r\!\left(d_{t,j}, I_{j}\right)\quad \mathcal{S}=\{j:\ p\_type_j=0,\ d_{t,j}\leq R\}", r"\mathrm{turbine\_risk\_score}(t) = \sum_{j \in \mathcal{S}} r\!\left(d_{t,j}, I_{j}\right)\quad \mathcal{S}=\{j:\ p\_type_j=0,\ d_{t,j}\leq R\}",
width_inches=min(content_width, 6.5), width_inches=min(content_width, 6.5),
font_size=26, font_size=26,
) )
_add_paragraph(doc, "For visualization and reporting, we use the log-transformed score", size_pt=10) _add_paragraph(doc, s.log_transformed, size_pt=10)
_add_math_formula( _add_math_formula(
doc, doc,
r"\mathrm{risk\_log}(t) = \log_{10}(\mathrm{risk\_score}(t) + 1)", r"\mathrm{risk\_log}(t) = \log_{10}(\mathrm{risk\_score}(t) + 1)",
width_inches=min(content_width, 6.5), width_inches=min(content_width, 6.5),
) )
# 2. Risk Score Interpretation _add_title(doc, s.risk_interpretation, size_pt=14)
_add_title(doc, "2. Risk Score Interpretation", size_pt=14) _add_paragraph(doc, s.understanding_risk, size_pt=11, bold=True)
_add_paragraph(doc, "Understanding Risk Score Values:", size_pt=11, bold=True)
_add_bullets( _add_bullets(
doc, doc,
[ [s.min_risk_score, s.max_risk_score, s.typical_range],
"Minimum Risk Score: ~0.001 (far distance, low current)",
"Maximum Risk Score: ~2.500 (close distance, high current)",
"Typical Range: 0.010 - 1.000 for most lightning events",
],
size_pt=10, size_pt=10,
) )
_add_paragraph(doc, "Risk Score Categories (Fixed Color Intervals):", size_pt=11, bold=True) _add_paragraph(doc, s.risk_categories, size_pt=11, bold=True)
_add_bullets( _add_bullets(
doc, doc,
[ [
"Very Low Risk (<0.1): Blue - Distant lightning with low current", s.risk_cat_very_low,
"Low Risk (0.1-0.2): Teal - Moderate distance lightning", s.risk_cat_low,
"Med-Low Risk (0.2-0.4): Green - Closer lightning", s.risk_cat_med_low,
"Medium Risk (0.4-0.6): Yellow - Moderate risk lightning", s.risk_cat_medium,
"Med-High Risk (0.6-0.8): Orange - High risk lightning", s.risk_cat_med_high,
"High Risk (0.8-1.0): Dark Orange - Very high risk lightning", s.risk_cat_high,
"Very High Risk (1.0-1.2): Red - Extreme risk lightning", s.risk_cat_very_high,
"Critical Risk (>1.2): Dark Red - Critical risk lightning", s.risk_cat_critical,
], ],
size_pt=10, size_pt=10,
) )
# 3. Risk Score Calculation Chart _add_title(doc, s.risk_chart, size_pt=14)
_add_title(doc, "3. Risk Score Calculation Chart", size_pt=14) _add_paragraph(doc, s.chart_reference, size_pt=11, bold=True)
_add_paragraph(doc, "Chart Reference Guide:", size_pt=11, bold=True) _add_paragraph(doc, s.risk_chart_desc_1, size_pt=10)
_add_paragraph(doc, "The following chart shows how distance and current magnitude affect risk scores.", size_pt=10) _add_paragraph(doc, s.risk_chart_desc_2, size_pt=10)
_add_paragraph(doc, "Use this chart to interpret the risk scores in the main report.", size_pt=10) _add_bullets(doc, [s.risk_chart_red, s.risk_chart_yellow, s.risk_chart_blue], size_pt=10)
_add_bullets(doc, ["Red areas = High risk (close distance, high current)", "Yellow/Orange areas = Medium risk", "Blue/Green areas = Low risk (far distance, low current)"], size_pt=10)
from src.visualization.maps import create_risk_score_heatmap from src.visualization.maps import create_risk_score_heatmap
heatmap_fig = create_risk_score_heatmap() heatmap_fig = create_risk_score_heatmap(lang=lang)
_add_image_from_bytes(doc, heatmap_fig.to_image(format="png", width=1400, height=900, scale=2, engine="kaleido"), content_width) _add_image_from_bytes(doc, heatmap_fig.to_image(format="png", width=1400, height=900, scale=2, engine="kaleido"), content_width)
# 4. Centroid and Distance Ring Calculation _add_title(doc, s.centroid_rings, size_pt=14)
_add_title(doc, "4. Centroid and Distance Ring Calculation", size_pt=14) _add_bullets(
doc,
[s.centroid_bullet_1, s.centroid_bullet_2, s.centroid_bullet_3, s.centroid_bullet_4],
size_pt=10,
)
_add_title(doc, s.freq_lightning_algo, size_pt=14)
_add_paragraph(doc, s.how_period_timespans, size_pt=11, bold=True)
_add_paragraph(doc, s.gap_based_algo, size_pt=10)
_add_paragraph(doc, s.step_by_step, size_pt=11, bold=True)
_add_bullets( _add_bullets(
doc, doc,
[ [
"Farm centroid is provided by the monitoring workflow and shared by every map and table in this report", s.algo_chronological,
"Distance rings are drawn from the farm centroid; innermost rings represent the highest proximity risk", s.algo_gap,
"Lightning proximity for each strike is measured from the farm centroid using the Haversine formula", s.algo_period_boundary.format(gap=config.histogram_params["min_gap_minutes"]),
"The monitoring boundary defines the outer analysis radius — strikes beyond it are excluded", s.algo_period_validation.format(min_events=config.histogram_params["min_events_per_period"]),
s.algo_timespan,
s.algo_peak,
], ],
size_pt=10, size_pt=10,
) )
# 5. Frequent Lightning Activity Period Detection Algorithm _add_title(doc, s.entln_title, size_pt=14)
_add_title(doc, "5. Frequent Lightning Activity Period Detection Algorithm", size_pt=14) for blk in (s.entln_block_1, s.entln_block_2, s.entln_block_3):
_add_paragraph(doc, "How Period Timespans Are Determined:", size_pt=11, bold=True)
_add_paragraph(doc, "The algorithm uses a gap-based approach to identify concentrated lightning activity periods.", size_pt=10)
_add_paragraph(doc, "Step-by-Step Process:", size_pt=11, bold=True)
_add_bullets(
doc,
[
"Chronological Sorting: All lightning events are sorted by timestamp (local_time)",
"Gap Calculation: Time differences between consecutive lightning events are calculated",
f"Period Boundary Detection: When a gap between two consecutive events exceeds {config.histogram_params['min_gap_minutes']} minutes, it marks the end of one period and the start of another",
f"Period Validation: Only periods with ≥{config.histogram_params['min_events_per_period']} lightning events are considered significant",
"Timespan Definition: Start time = first event; End time = last event; Duration is actual time from first to last event",
"Peak Sub-Period Detection: 3-minute rolling average; peaks when activity exceeds mean + 1 standard deviation; highlighted in yellow on histogram",
],
size_pt=10,
)
# 6. EarthNetworks Total Lightning Network (ENTLN)
_add_title(doc, "6. EarthNetworks Total Lightning Network (ENTLN)", size_pt=14)
_entln_blocks = [
"The lightning data presented in this report is sourced directly from the EarthNetworks Total Lightning Network (ENTLN), which monitors both in-cloud and cloud-to-ground strikes. ENTLN is the first in-cloud lightning and cloud-to-ground detection network deployed on a global basis. It includes more than 1,500 sensors (the world's largest) that incorporate advanced systems and technology to provide unmatched in-cloud and cloud-to-ground lightning detection total lightning detection.",
"A variety of government agencies, such as the National Weather Service, the Air Force Weather Agency, and organizations that include public safety and emergency response agencies, airports, and utilities rely on information from the ENTLN to make informed decisions.",
"Its unique capabilities detect long-range in-cloud lightning at high efficiencies, which are critical for the advanced prediction of severe weather such as:",
]
for blk in _entln_blocks:
_add_paragraph(doc, blk, size_pt=10) _add_paragraph(doc, blk, size_pt=10)
_add_bullets( _add_bullets(
doc, doc,
[ [s.entln_tornadoes, s.entln_rainfall, s.entln_downburst, s.entln_cg_strikes],
"Tornadoes and cyclones",
"Heavy rainfall and monsoons",
"Downburst and wind shear",
"Cloud-to-Ground lightning strikes",
],
size_pt=10, size_pt=10,
) )
_add_paragraph(doc, "These potentially deadly weather events often occur within 5 to 30 minutes of in-cloud flash initiation. ENTLN has demonstrated the ability to significantly improve severe weather warning times over radar and other technologies by incorporating predictive capabilities that are crucial for characterizing severe storm precursors, improving severe storm warning lead times, and supporting comprehensive weather management planning.", size_pt=10) _add_paragraph(doc, s.entln_block_4, size_pt=10)
_add_paragraph(doc, "The total lightning network has expanded to include more than 1,500 sensors in more than 40 countries around the world, including North and South America, the Caribbean, Europe, Australia, Africa and Asia. This dense sensor deployment enables high-efficiency capture of total lightning activity across these regions.", size_pt=10) _add_paragraph(doc, s.entln_block_5, size_pt=10)
earth_logo = _resolve_logo_path("earth_networks.jpg") earth_logo = _resolve_logo_path("earth_networks.jpg")
if os.path.isfile(earth_logo): if os.path.isfile(earth_logo):

View File

@ -8,6 +8,7 @@ import plotly.graph_objects as go
from src.config import config from src.config import config
from src.reporting.precompute import precompute_distances_and_rings from src.reporting.precompute import precompute_distances_and_rings
from src.reporting.strings import ReportLanguage, get_report_language, get_strings
def _parse_chart_times(values: pd.Series) -> pd.Series: def _parse_chart_times(values: pd.Series) -> pd.Series:
@ -50,7 +51,9 @@ def _build_current_vs_distance_chart(
title: str, title: str,
fig_width: int, fig_width: int,
fig_height: int, fig_height: int,
lang: ReportLanguage | None = None,
) -> go.Figure | None: ) -> go.Figure | None:
s = get_strings(lang or get_report_language())
if lightning_type_filter == "cg": if lightning_type_filter == "cg":
type_mask = lightning_df["p_type"].astype(str) == "0" type_mask = lightning_df["p_type"].astype(str) == "0"
else: else:
@ -111,10 +114,10 @@ def _build_current_vs_distance_chart(
marker=dict(size=10, opacity=0.8, color=color), marker=dict(size=10, opacity=0.8, color=color),
customdata=np.column_stack((r_dists, r_time_labels)), customdata=np.column_stack((r_dists, r_time_labels)),
hovertemplate=( hovertemplate=(
"Time: %{customdata[1]}<br>" f"{s.chart_time}: %{{customdata[1]}}<br>"
"Current: %{y:.0f} A<br>" f"{s.chart_current}: %{{y:.0f}} A<br>"
"Distance: %{customdata[0]} km<br>" f"{s.chart_distance}: %{{customdata[0]}} km<br>"
"Ring: " + ring_names[i] + "<br>" f"{s.chart_ring}: " + ring_names[i] + "<br>"
"<extra></extra>" "<extra></extra>"
), ),
) )
@ -125,8 +128,8 @@ def _build_current_vs_distance_chart(
fig.update_layout( fig.update_layout(
font=dict(size=16), font=dict(size=16),
title=dict(text=title, x=0.5, font=dict(size=22)), title=dict(text=title, x=0.5, font=dict(size=22)),
xaxis_title=f"Time ({timezone_label})", xaxis_title=f"{s.chart_time} ({timezone_label})",
yaxis_title="Current (A)", yaxis_title=s.chart_current_axis,
plot_bgcolor="white", plot_bgcolor="white",
paper_bgcolor="white", paper_bgcolor="white",
xaxis=dict( xaxis=dict(
@ -148,7 +151,7 @@ def _build_current_vs_distance_chart(
title_font=dict(size=28), title_font=dict(size=28),
), ),
legend=dict( legend=dict(
title="Distance Ring", title=s.chart_distance_ring,
orientation="h", orientation="h",
x=0.5, x=0.5,
xanchor="center", xanchor="center",
@ -186,21 +189,23 @@ def _turbine_cell_value_is_available(value: Any) -> bool:
def build_turbine_information_table_data( def build_turbine_information_table_data(
turbine_df: pd.DataFrame, turbine_df: pd.DataFrame,
lang: ReportLanguage | None = None,
) -> list[list[str]]: ) -> list[list[str]]:
s = get_strings(lang or get_report_language())
column_specs: list[tuple[str, str, Callable[[pd.Series], str], bool]] = [ column_specs: list[tuple[str, str, Callable[[pd.Series], str], bool]] = [
("Turbine", "name", lambda t: str(t.get("name", "N/A")), True), (s.col_turbine, "name", lambda t: str(t.get("name", "N/A")), True),
("Lat", "lat", lambda t: f"{t.get('lat', 0):.4f}", True), (s.col_lat, "lat", lambda t: f"{t.get('lat', 0):.4f}", True),
("Lng", "lng", lambda t: f"{t.get('lng', 0):.4f}", True), (s.col_lng, "lng", lambda t: f"{t.get('lng', 0):.4f}", True),
("Unit Power (MWm)", "unit_power_mwm", lambda t: str(t.get("unit_power_mwm", "N/A")), False), (s.col_unit_power_mwm, "unit_power_mwm", lambda t: str(t.get("unit_power_mwm", "N/A")), False),
("Unit Power (MWe)", "unit_power_mwe", lambda t: str(t.get("unit_power_mwe", "N/A")), False), (s.col_unit_power_mwe, "unit_power_mwe", lambda t: str(t.get("unit_power_mwe", "N/A")), False),
("Tower Height (m)", "tower_height_m", lambda t: str(t.get("tower_height_m", "N/A")), False), (s.col_tower_height, "tower_height_m", lambda t: str(t.get("tower_height_m", "N/A")), False),
( (
"Rotor Diameter (m)", s.col_rotor_diameter,
"turbine_rotor_blade_diameter", "turbine_rotor_blade_diameter",
lambda t: str(t.get("turbine_rotor_blade_diameter", "N/A")), lambda t: str(t.get("turbine_rotor_blade_diameter", "N/A")),
False, False,
), ),
("Altitude (m)", "altitude", lambda t: str(t.get("altitude", "N/A")), False), (s.col_altitude, "altitude", lambda t: str(t.get("altitude", "N/A")), False),
] ]
active_columns: list[tuple[str, Callable[[pd.Series], str]]] = [] active_columns: list[tuple[str, Callable[[pd.Series], str]]] = []
@ -218,8 +223,12 @@ def build_turbine_information_table_data(
def build_lightning_event_table_data( def build_lightning_event_table_data(
centroid_lat: float, centroid_lng: float, lightning_df: pd.DataFrame centroid_lat: float,
centroid_lng: float,
lightning_df: pd.DataFrame,
lang: ReportLanguage | None = None,
) -> tuple[list[list[str]], list[str]]: ) -> tuple[list[list[str]], list[str]]:
s = get_strings(lang or get_report_language())
pre = precompute_distances_and_rings( pre = precompute_distances_and_rings(
centroid_lat, centroid_lng, lightning_df, config.distance_rings centroid_lat, centroid_lng, lightning_df, config.distance_rings
) )
@ -248,7 +257,7 @@ def build_lightning_event_table_data(
except Exception: except Exception:
local_time = str(getattr(rec, "local_time", ""))[:19] local_time = str(getattr(rec, "local_time", ""))[:19]
lightning_type = "cloud-to-ground" if str(rec.p_type) == "0" else "intercloud" lightning_type = s.lightning_type_cg if str(rec.p_type) == "0" else s.lightning_type_ic
height_val = getattr(rec, "ic_height", "") height_val = getattr(rec, "ic_height", "")
if height_val == "": if height_val == "":
height_val = getattr(rec, "height", "") height_val = getattr(rec, "height", "")
@ -269,7 +278,7 @@ def build_lightning_event_table_data(
sorted_data = sorted( sorted_data = sorted(
zip(rows, row_colors), zip(rows, row_colors),
key=lambda x: (0 if x[0][6] == "cloud-to-ground" else 1, float(x[0][7])), key=lambda x: (0 if x[0][6] == s.lightning_type_cg else 1, float(x[0][7])),
) )
if sorted_data: if sorted_data:
rows, row_colors = zip(*sorted_data) rows, row_colors = zip(*sorted_data)
@ -281,24 +290,26 @@ def build_lightning_event_table_data(
rows, row_colors = [], [] rows, row_colors = [], []
header = [ header = [
"#", s.col_no,
"Time (Local)", s.col_time_local,
"Lat", s.col_lat,
"Lng", s.col_lng,
"Current (amps)", s.col_current_amps,
"Height (m)", s.col_height_m,
"Lightning Type", s.col_lightning_type,
"Proximity (km)", s.col_proximity_km,
] ]
return [header] + rows, ["lightgrey"] + row_colors return [header] + rows, ["lightgrey"] + row_colors
def build_risk_table_data( def build_risk_table_data(
turbine_df: pd.DataFrame, turbine_df: pd.DataFrame,
lang: ReportLanguage | None = None,
) -> tuple[list[list[str]] | None, list[str] | None]: ) -> tuple[list[list[str]] | None, list[str] | None]:
if "risk_log" not in turbine_df.columns: if "risk_log" not in turbine_df.columns:
return None, None return None, None
s = get_strings(lang or get_report_language())
rows: list[list[str]] = [] rows: list[list[str]] = []
row_colors: list[str] = [] row_colors: list[str] = []
@ -311,7 +322,7 @@ def build_risk_table_data(
[ [
str(turbine.get("name", "N/A")), str(turbine.get("name", "N/A")),
f"{risk_log:.2f}", f"{risk_log:.2f}",
str(get_risk_definition_by_fixed_intervals(risk_log)), str(get_risk_definition_by_fixed_intervals(risk_log, lang or get_report_language())),
] ]
) )
row_colors.append(str(color)) row_colors.append(str(color))
@ -322,6 +333,6 @@ def build_risk_table_data(
else: else:
rows, row_colors = [], [] rows, row_colors = [], []
header = ["Turbine Name", "Log Risk", "Risk Definition"] header = [s.col_turbine_name, s.col_log_risk, s.col_risk_definition]
return [header] + list(rows), ["lightgrey"] + list(row_colors) return [header] + list(rows), ["lightgrey"] + list(row_colors)

View File

@ -3,44 +3,165 @@ from __future__ import annotations
import os import os
from typing import Any from typing import Any
from src.reporting.strings import ReportLanguage, get_report_language, get_strings
from src.utils import get_risk_definition_by_fixed_intervals from src.utils import get_risk_definition_by_fixed_intervals
def build_gemini_prompt(context: dict[str, Any]) -> str: def _format_ring_label(ring_name: str, turkish: bool) -> str:
label = str(ring_name).strip().rstrip(":")
if turkish:
label = label.replace("km", " km").replace("-", "").replace(".", ",")
return label
def _format_ring_display(ring: Any, s, *, for_distribution: bool = False) -> str:
try:
ring_name, total, cg_count, ic_count = ring
except Exception:
return "N/A"
label = _format_ring_label(ring_name, s.gemini_write_turkish)
if s.gemini_write_turkish:
if for_distribution:
return (
f"{label} halkasında "
f"({cg_count} {s.lightning_type_cg}, {ic_count} {s.lightning_type_ic})"
)
return (
f"{label} halkasında toplam {total} olay "
f"({cg_count} {s.lightning_type_cg}, {ic_count} {s.lightning_type_ic})"
)
return f"{label} (total={total}, {s.lightning_type_cg}={cg_count}, {s.lightning_type_ic}={ic_count})"
def _ring_lines(context: dict[str, Any], s) -> list[str]:
top_rings = context.get("top_rings", [])
lines: list[str] = []
for ring in top_rings[:3]:
lines.append(f"- {_format_ring_display(ring, s)}")
return lines
def _build_ring_distribution_sentence_tr(best_ring: Any, outer_ring: Any, s) -> str:
best_txt = _format_ring_display(best_ring, s, for_distribution=True)
if outer_ring is None or outer_ring == best_ring:
return f"Yıldırım olayları {best_txt} yoğunlaşmıştır."
try:
_, outer_total, _, _ = outer_ring
except Exception:
outer_total = 0
if outer_total > 0:
outer_txt = _format_ring_display(outer_ring, s, for_distribution=True)
return f"Olayların büyük bölümü {best_txt}; {outer_txt} de kayıt bulunmaktadır."
return f"Yıldırım olayları {best_txt} yoğunlaşmıştır."
def _build_ring_distribution_sentence_en(best_ring: Any, outer_ring: Any, s) -> str:
best_txt = _format_ring_display(best_ring, s)
if outer_ring is None or outer_ring == best_ring:
return f"Lightning activity is concentrated in {best_txt}."
try:
_, outer_total, _, _ = outer_ring
except Exception:
outer_total = 0
if outer_total > 0:
outer_txt = _format_ring_display(outer_ring, s)
return f"Most events occur in {best_txt}, with additional activity in {outer_txt}."
return f"Lightning activity is concentrated in {best_txt}."
def _storm_lines(context: dict[str, Any]) -> list[str]:
storm_summary = context.get("storm_summary")
lines: list[str] = []
if isinstance(storm_summary, dict) and storm_summary:
total_cells = storm_summary.get("total_cells", 0)
severity_counts = storm_summary.get("severity_counts", {}) or {}
lines.append(f"- total_cells={total_cells}")
for severity, count in severity_counts.items():
lines.append(f"- {severity}_cells={count}")
return lines
def build_gemini_prompt(context: dict[str, Any], language: ReportLanguage | None = None) -> str:
lang = language or get_report_language()
s = get_strings(lang)
analysis_period = context.get("analysis_period", "N/A") analysis_period = context.get("analysis_period", "N/A")
analysis_radius_km = context.get("analysis_radius_km", None) analysis_radius_km = context.get("analysis_radius_km", None)
total_events = context.get("total_events", None) total_events = context.get("total_events", None)
total_lightning_per_km2 = context.get("total_lightning_per_km2", None) total_lightning_per_km2 = context.get("total_lightning_per_km2", None)
turbine_count = context.get("turbine_count", None) turbine_count = context.get("turbine_count", None)
is_single_turbine_report = context.get("is_single_turbine_report", None) is_single_turbine_report = context.get("is_single_turbine_report", None)
top_rings = context.get("top_rings", []) # list of (ring_name, total, cg_count, ic_count)
max_risk_log = context.get("max_risk_log", None) max_risk_log = context.get("max_risk_log", None)
max_risk_definition = context.get("max_risk_definition", None) max_risk_definition = context.get("max_risk_definition", None)
top_turbine_name = context.get("top_turbine_name", "N/A") top_turbine_name = context.get("top_turbine_name", "N/A")
top_turbine_risk_log = context.get("top_turbine_risk_log", None) top_turbine_risk_log = context.get("top_turbine_risk_log", None)
storm_summary = context.get("storm_summary")
storm_over_turbine = context.get("storm_over_turbine") storm_over_turbine = context.get("storm_over_turbine")
storm_near_turbine_count = context.get("storm_near_turbine_count") storm_near_turbine_count = context.get("storm_near_turbine_count")
storm_closest_distance_km = context.get("storm_closest_distance_km") storm_closest_distance_km = context.get("storm_closest_distance_km")
storm_over_threshold_km = context.get("storm_over_threshold_km", 1.0) storm_over_threshold_km = context.get("storm_over_threshold_km", 1.0)
ring_lines: list[str] = [] ring_lines = _ring_lines(context, s)
for ring in top_rings[:3]: storm_lines = _storm_lines(context)
try:
ring_name, total, cg_count, ic_count = ring
except Exception:
continue
ring_lines.append(f"- {ring_name}: total={total}, cloud-to-ground={cg_count}, intercloud={ic_count}")
storm_lines: list[str] = [] if s.gemini_write_turkish:
if isinstance(storm_summary, dict) and storm_summary: return (
total_cells = storm_summary.get("total_cells", 0) "Yıldırım aktivite raporu için tek bir tarafsız, olgusal yorum paragrafı yazıyorsun.\n"
severity_counts = storm_summary.get("severity_counts", {}) or {} "Tam olarak 3-4 cümle yaz. Akıcı ve doğal Türkçe kullan.\n"
storm_lines.append(f"- total_cells={total_cells}") "Sayı uydurma. Yalnızca verilen değerleri kullan.\n"
for severity, count in severity_counts.items(): "\n"
storm_lines.append(f"- {severity}_cells={count}") "Dil ve üslup:\n"
"- Çeviri kokan, bürokratik veya yapay ifadelerden kaçın.\n"
"- Şunları kullanma: \"içermekte olup\", \"görülmektedir\", \"yoğunlaşmış olup\", \"mevcut değildir\", \"bulut-yere\", \"bulut içi\".\n"
"- Yıldırım türleri için \"yıldırım\" (yere indirme) ve \"şimşek\" (bulut içi) terimlerini kullan.\n"
"- Mesafe halkalarını \"XY km halkası\" biçiminde ifade et.\n"
"- Olay sayısı sıfır olan halkalardan bahsetme.\n"
"\n"
"Risk açıklama gereksinimleri (paragrafta kısa ve anlaşılır biçimde yansıtılmalı):\n"
"- Türbin riski, yere indirme yıldırımlarında akım büyüklüğü arttıkça artar.\n"
"- Türbin riski, türbine olan mesafe arttıkça üstel olarak azalır.\n"
"- Türbin risk skoru, darbe başına katkıların toplamıdır (görselleştirme için log dönüşümü uygulanır).\n"
"\n"
"Bağlam:\n"
f"- analysis_period: {analysis_period}\n"
f"- analysis_radius_km: {analysis_radius_km}\n"
f"- total_events: {total_events}\n"
f"- total_lightning_per_km2: {total_lightning_per_km2}\n"
f"- turbine_count: {turbine_count}\n"
f"- is_single_turbine_report: {is_single_turbine_report}\n"
f"- top_rings:\n{chr(10).join(ring_lines) if ring_lines else '- N/A'}\n"
f"- max_risk_log: {max_risk_log}\n"
f"- max_risk_definition: {max_risk_definition}\n"
f"- top_turbine_name: {top_turbine_name}\n"
f"- top_turbine_risk_log: {top_turbine_risk_log}\n"
f"- storm_over_turbine: {storm_over_turbine}\n"
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"\n- storm_summary:\n{chr(10).join(storm_lines)}" if storm_lines else "\n- storm_summary: not available")
+ "\n\n"
"Paragraf gereksinimleri:\n"
"- İlk cümlede analiz dönemini, toplam olay sayısını, analiz yarıçapını ve yıldırım yoğunluğunu (olay/km²) belirt.\n"
"- Mesafe halkası dağılımından bir önemli çıkarım ekle; olayların hangi halkada yoğunlaştığınııkça söyle.\n"
"- 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"
"- 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"
"Örnek üslup (yalnızca stil rehberi; sayıları kopyalama):\n"
"\"03-05-2026 10:4303-05-2026 10:43 döneminde 9,0 km yarıçaplı analiz alanında toplam 2 yıldırım olayı kaydedilmiştir. "
"Yıldırım yoğunluğu 0,008 olay/km² olarak hesaplanmıştır. Yıldırım olayları 1,03,0 km halkasında (2 yıldırım, 0 şimşek) yoğunlaşmıştır. "
"T5 türbini için log-risk skoru Düşük Risk sınıfındadır. Bu raporda fırtına hücresi verisi bulunmamaktadır.\"\n"
"\n"
"Çıktı:\n"
"Yalnızca bir paragraf (madde işareti veya başlık yok)."
)
return ( return (
"You are generating a single neutral, factual commentary paragraph for a lightning activity report.\n" "You are generating a single neutral, factual commentary paragraph for a lightning activity report.\n"
@ -94,17 +215,18 @@ def build_gemini_prompt(context: dict[str, Any]) -> str:
) )
def fallback_commentary(context: dict[str, Any]) -> str: def fallback_commentary(context: dict[str, Any], language: ReportLanguage | None = None) -> str:
lang = language or get_report_language()
s = get_strings(lang)
analysis_period = context.get("analysis_period", "N/A") analysis_period = context.get("analysis_period", "N/A")
analysis_radius_km = context.get("analysis_radius_km", None) analysis_radius_km = context.get("analysis_radius_km", None)
total_events = context.get("total_events", None) total_events = context.get("total_events", None)
total_lightning_per_km2 = context.get("total_lightning_per_km2", None) total_lightning_per_km2 = context.get("total_lightning_per_km2", None)
turbine_count = context.get("turbine_count", None)
is_single_turbine_report = context.get("is_single_turbine_report", None) is_single_turbine_report = context.get("is_single_turbine_report", None)
top_rings = context.get("top_rings", []) top_rings = context.get("top_rings", [])
max_risk_definition = context.get("max_risk_definition", "N/A") max_risk_definition = context.get("max_risk_definition", "N/A")
top_turbine_name = context.get("top_turbine_name", "N/A") top_turbine_name = context.get("top_turbine_name", "N/A")
top_turbine_risk_log = context.get("top_turbine_risk_log", None)
storm_over_turbine = context.get("storm_over_turbine") storm_over_turbine = context.get("storm_over_turbine")
storm_closest_distance_km = context.get("storm_closest_distance_km") storm_closest_distance_km = context.get("storm_closest_distance_km")
storm_over_threshold_km = context.get("storm_over_threshold_km", 1.0) storm_over_threshold_km = context.get("storm_over_threshold_km", 1.0)
@ -113,76 +235,107 @@ def fallback_commentary(context: dict[str, Any]) -> str:
outermost_ring = top_rings[-1] if top_rings else None outermost_ring = top_rings[-1] if top_rings else None
best_ring = top_rings[0] if top_rings else None best_ring = top_rings[0] if top_rings else None
def _format_ring(ring: Any) -> str:
try:
ring_name, total, cg_count, ic_count = ring
return f"{ring_name} (total={total}, cloud-to-ground={cg_count}, intercloud={ic_count})"
except Exception:
return "N/A"
best_ring_txt = _format_ring(best_ring)
outer_ring_txt = _format_ring(outermost_ring)
storm_summary = context.get("storm_summary") or {} storm_summary = context.get("storm_summary") or {}
storm_line = "" storm_line = ""
if isinstance(storm_summary, dict) and storm_summary: if isinstance(storm_summary, dict) and storm_summary:
total_cells = storm_summary.get("total_cells", 0) total_cells = storm_summary.get("total_cells", 0)
severity_counts = storm_summary.get("severity_counts", {}) or {} severity_counts = storm_summary.get("severity_counts", {}) or {}
if severity_counts: if severity_counts:
# Pick max severity to mention
severity = max(severity_counts.items(), key=lambda kv: kv[1])[0] severity = max(severity_counts.items(), key=lambda kv: kv[1])[0]
count = severity_counts.get(severity, 0) count = severity_counts.get(severity, 0)
storm_line = f"Storm data indicates {total_cells} storm cells, with the highest share in {severity} ({count} cells)." if s.gemini_write_turkish:
storm_line = (
f"Toplam {total_cells} fırtına hücresi kaydedilmiş; en fazla {count} hücre {severity} şiddetindedir."
)
else:
storm_line = (
f"Storm data indicates {total_cells} storm cells, with the highest share in {severity} ({count} cells)."
)
elif s.gemini_write_turkish:
storm_line = f"Toplam {total_cells} fırtına hücresi kaydedilmiştir."
else: else:
storm_line = f"Storm data indicates {total_cells} storm cells." storm_line = f"Storm data indicates {total_cells} storm cells."
density_txt = ( density_txt = (
f"{total_lightning_per_km2:.3f} events/km²" if isinstance(total_lightning_per_km2, (int, float)) else str(total_lightning_per_km2) f"{total_lightning_per_km2:.3f} {s.events_per_km2}"
if isinstance(total_lightning_per_km2, (int, float))
else str(total_lightning_per_km2)
) )
radius_txt = f"within {analysis_radius_km:.1f} km" if isinstance(analysis_radius_km, (int, float)) else "" if s.gemini_write_turkish:
events_txt = f"{total_events} total lightning events" if total_events is not None else "N/A" radius_txt = (
f"{analysis_radius_km:.1f} km yarıçaplı analiz alanında"
paragraph_intro = ( if isinstance(analysis_radius_km, (int, float))
f"For {analysis_period}, the dataset contains {events_txt} {radius_txt}, corresponding to an overall lightning density of {density_txt}. " else "analiz alanında"
f"The largest contributions are concentrated in {best_ring_txt}, with additional activity also present in {outer_ring_txt}. "
)
turbine_sentence = (
f"For {top_turbine_name}, the log-transformed risk score is the highest in this report and falls in the {max_risk_definition} category. "
if is_single_turbine_report
else f"Within the analyzed area, the turbine with the highest log-transformed risk score is {top_turbine_name}, which falls in the {max_risk_definition} category. "
)
method_sentence = (
f"This indicates the turbine was exposed to a combination of closer cloud-to-ground strikes and stronger current magnitudes. "
f"In the risk model, each cloud-to-ground strike contributes more when it is near the turbine and when |I| is larger, and contributions decrease exponentially with distance; the turbine risk score is the sum over all included strikes (with a log transform used for visualization). "
)
if storm_over_turbine:
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_distance_km, (int, float)): events_txt = f"toplam {total_events} yıldırım olayı" if total_events is not None else "yıldırım olayı"
storm_interaction_sentence = ( ring_sentence = _build_ring_distribution_sentence_tr(best_ring, outermost_ring, s)
f"Storm interaction: the closest storm-cell centroid came within {storm_closest_distance_km:.1f} km of the turbine. " paragraph_intro = (
f"{analysis_period} döneminde {radius_txt} {events_txt} tespit edilmiştir. "
f"Yıldırım yoğunluğu {density_txt} olarak hesaplanmıştır. "
f"{ring_sentence} "
) )
turbine_sentence = (
f"{top_turbine_name} türbini için log-risk skoru {max_risk_definition} sınıfındadır. "
if is_single_turbine_report
else f"Analiz alanında en yüksek log-risk skoruna sahip türbin {top_turbine_name} olup {max_risk_definition} sınıfındadır. "
)
method_sentence = (
"Risk skoru, türbine yakınlık ve akım büyüklüğünün birleşimini yansıtır; "
"modele göre her yere indirme darbesinin katkısı mesafe arttıkça üstel olarak azalır. "
)
if storm_over_turbine:
storm_interaction_sentence = (
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_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."
else: else:
storm_interaction_sentence = "" 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"
ring_sentence = _build_ring_distribution_sentence_en(best_ring, outermost_ring, s)
paragraph_intro = (
f"For {analysis_period}, the dataset contains {events_txt} {radius_txt}, corresponding to an overall lightning density of {density_txt}. "
f"{ring_sentence} "
)
turbine_sentence = (
f"For {top_turbine_name}, the log-transformed risk score is the highest in this report and falls in the {max_risk_definition} category. "
if is_single_turbine_report
else f"Within the analyzed area, the turbine with the highest log-transformed risk score is {top_turbine_name}, which falls in the {max_risk_definition} category. "
)
method_sentence = (
f"This indicates the turbine was exposed to a combination of closer cloud-to-ground strikes and stronger current magnitudes. "
f"In the risk model, each cloud-to-ground strike contributes more when it is near the turbine and when |I| is larger, and contributions decrease exponentially with distance; the turbine risk score is the sum over all included strikes (with a log transform used for visualization). "
)
if storm_over_turbine:
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_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 if storm_line else "Storm severity distribution is not available for this report." return (paragraph_intro + turbine_sentence + method_sentence + storm_interaction_sentence + storm_severity_sentence).strip()
paragraph = (paragraph_intro + turbine_sentence + method_sentence + storm_interaction_sentence + storm_severity_sentence).strip()
return paragraph
def generate_gemini_paragraph(context: dict[str, Any], api_key: str | None = None) -> str: def generate_gemini_paragraph(context: dict[str, Any], api_key: str | None = None) -> str:
lang = get_report_language()
api_key_final = api_key or os.getenv("GEMINI_API_KEY") api_key_final = api_key or os.getenv("GEMINI_API_KEY")
if not api_key_final: if not api_key_final:
return fallback_commentary(context) return fallback_commentary(context, lang)
model_name = os.getenv("GEMINI_MODEL", "gemini-1.5-flash") model_name = os.getenv("GEMINI_MODEL", "gemini-1.5-flash")
prompt = build_gemini_prompt(context, lang)
prompt = build_gemini_prompt(context)
try: try:
import google.generativeai as genai import google.generativeai as genai
@ -190,7 +343,6 @@ def generate_gemini_paragraph(context: dict[str, Any], api_key: str | None = Non
genai.configure(api_key=api_key_final) genai.configure(api_key=api_key_final)
model = genai.GenerativeModel(model_name) model = genai.GenerativeModel(model_name)
# Keep output short and deterministic
resp = model.generate_content( resp = model.generate_content(
prompt, prompt,
generation_config={ generation_config={
@ -202,8 +354,7 @@ def generate_gemini_paragraph(context: dict[str, Any], api_key: str | None = Non
text = getattr(resp, "text", None) or "" text = getattr(resp, "text", None) or ""
text = str(text).strip() text = str(text).strip()
if not text: if not text:
return fallback_commentary(context) return fallback_commentary(context, lang)
return text return text
except Exception: except Exception:
return fallback_commentary(context) return fallback_commentary(context, lang)

643
src/reporting/strings.py Normal file
View File

@ -0,0 +1,643 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
ReportLanguage = Literal["en", "tr"]
def normalize_language(value: str | None) -> ReportLanguage:
if not value:
return "en"
key = str(value).strip().lower().replace(" ", "").replace("_", "")
if key in {"tr", "turkish", "turkce", "türkçe", "turkçe"}:
return "tr"
return "en"
def get_report_language() -> ReportLanguage:
from src.config import config
return normalize_language(getattr(config, "language", None))
@dataclass(frozen=True)
class ReportStrings:
cover_title: str
analyzed_period: str
analyzed_period_local: str
centroid_coordinates: str
latitude: str
longitude: str
report_generated: str
cover_intro_1: str
cover_intro_2: str
cover_intro_3: str
wind_farm_default: str
turbine_information: str
turbine_information_desc: str
report_summary: str
cloud_to_ground_lightnings: str
cloud_to_ground_current_vs_distance: str
cg_chart_title: str
intercloud_lightnings: str
intercloud_current_vs_distance: str
ic_chart_title: str
turbine_risk_assessment: str
lightning_breakdown_rings: str
period_total_events: str
area_covered: str
total_lightnings_radius: str
total_density: str
density_calc: str
daily_density: str
daily_density_calc: str
day_in_period: str
days_in_period: str
ring_section_intro: str
ring_closer_risk: str
ring_item: str
frequent_lightning_report: str
lightning_activity_overview: str
histogram_overview: str
histogram_appendix_ref: str
frequent_lightning_periods: str
storm_cells_analysis_summary: str
storm_cells_overview: str
storm_cells_overview_1: str
storm_cells_overview_2: str
storm_cells_summary: str
total_storm_cells: str
severity_breakdown: str
severity_cells: str
avg_direction: str
avg_speed: str
daily_storm_breakdown: str
daily_storm_item: str
more_days: str
complete_storm_list: str
storm_cells: str
storm_cells_daily_viz: str
detailed_lightning_data: str
appendix: str
risk_calc_method: str
how_risk_determined: str
risk_exposure_1: str
risk_exposure_2: str
p0_desc: str
current_weight_desc: str
distance_desc: str
current_desc: str
alpha_desc: str
included_events: str
per_strike_contribution: str
turbine_risk_sum: str
log_transformed: str
risk_interpretation: str
understanding_risk: str
min_risk_score: str
max_risk_score: str
typical_range: str
risk_categories: str
risk_cat_very_low: str
risk_cat_low: str
risk_cat_med_low: str
risk_cat_medium: str
risk_cat_med_high: str
risk_cat_high: str
risk_cat_very_high: str
risk_cat_critical: str
risk_chart: str
chart_reference: str
risk_chart_desc_1: str
risk_chart_desc_2: str
risk_chart_red: str
risk_chart_yellow: str
risk_chart_blue: str
centroid_rings: str
centroid_bullet_1: str
centroid_bullet_2: str
centroid_bullet_3: str
centroid_bullet_4: str
freq_lightning_algo: str
how_period_timespans: str
gap_based_algo: str
step_by_step: str
algo_chronological: str
algo_gap: str
algo_period_boundary: str
algo_period_validation: str
algo_timespan: str
algo_peak: str
entln_title: str
entln_block_1: str
entln_block_2: str
entln_block_3: str
entln_tornadoes: str
entln_rainfall: str
entln_downburst: str
entln_cg_strikes: str
entln_block_4: str
entln_block_5: str
risk_color_legend: str
risk_legend_very_low: str
risk_legend_low: str
risk_legend_med_low: str
risk_legend_medium: str
risk_legend_med_high: str
risk_legend_high: str
risk_legend_very_high: str
risk_legend_critical: str
risk_legend_maximum: str
col_turbine: str
col_lat: str
col_lng: str
col_unit_power_mwm: str
col_unit_power_mwe: str
col_tower_height: str
col_rotor_diameter: str
col_altitude: str
col_no: str
col_time_local: str
col_current_amps: str
col_height_m: str
col_lightning_type: str
col_proximity_km: str
col_turbine_name: str
col_log_risk: str
col_risk_definition: str
col_severity: str
col_effective_time: str
col_expire_time: str
lightning_type_cg: str
lightning_type_ic: str
chart_time: str
chart_current: str
chart_distance: str
chart_ring: str
chart_distance_ring: str
chart_current_axis: str
events_per_km2: str
events_per_km2_day: str
unknown: str
commentary_prefixes: tuple[str, ...]
gemini_write_turkish: bool
map_longitude: str
map_latitude: str
map_legend: str
map_cg_plane_title: str
map_ic_plane_title: str
map_wind_turbines: str
map_storm_cells: str
map_cg_lightning: str
map_ic_lightning: str
map_distance_ring: str
hist_minutes_from_start: str
hist_lightning_count: str
storm_cells_day_title: str
heatmap_title: str
heatmap_subtitle: str
heatmap_xaxis: str
heatmap_yaxis: str
heatmap_risk_level: str
heatmap_risk_ticks: tuple[str, ...]
storm_hover_severity: str
storm_hover_effective: str
storm_hover_expire: str
storm_hover_direction: str
storm_hover_speed: str
_STRINGS_EN = ReportStrings(
cover_title="Lightning Activity Report for",
analyzed_period="Analyzed Period:",
analyzed_period_local="Analyzed Period (local time):",
centroid_coordinates="Centroid Coordinates:",
latitude="Latitude",
longitude="Longitude",
report_generated="Report Generated",
cover_intro_1="This report provides comprehensive lightning and storm activity analysis for wind farm",
cover_intro_2="operations and safety assessment. Each section includes detailed explanations to help",
cover_intro_3="you understand the data and make informed decisions about turbine safety",
wind_farm_default="Wind Farm",
turbine_information="Turbine Information",
turbine_information_desc="This table contains detailed information about all turbines in the wind farm.",
report_summary="Report Summary",
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",
intercloud_lightnings="Intercloud Lightnings",
intercloud_current_vs_distance="Intercloud — Current vs Distance",
ic_chart_title="Intercloud Lightning — Current vs Distance from Centroid",
turbine_risk_assessment="Turbine Risk Assessment",
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²",
total_lightnings_radius="Total lightnings within {radius:.1f} km radius: {total} events",
total_density="Total lightning density: {density:.3f} events/km²",
density_calc="(Calculation: {total} total lightnings / {area:.1f} km² area)",
daily_density="Daily equivalent density: {density:.3f} events/km²/day",
daily_density_calc="(Calculation: {total} total lightnings / {area:.1f} km² area / {days_label})",
day_in_period="1 day in the period",
days_in_period="{days} days in the period",
ring_section_intro="This section shows lightning events over the analyzed period by distance from turbines.",
ring_closer_risk="Higher counts in closer rings (0-{closest:.1f}km) indicate elevated risk to turbine operations.",
ring_item="{ring}: {total} total ({cg} cloud-to-ground, {ic} intercloud)",
frequent_lightning_report="Frequent Lightning Activity Report",
lightning_activity_overview="Lightning Activity Overview:",
histogram_overview="The chart below shows lightning activity patterns over time within {radius:.1f} km radius, helping identify high-risk periods and concentrated storm events. Peaks indicate intense lightning activity requiring attention.",
histogram_appendix_ref='For detailed information go to the "Frequent Lightning Activity Period Detection Algorithm" section in the appendix.',
frequent_lightning_periods="Frequent Lightning Activity Periods",
storm_cells_analysis_summary="Storm Cells Analysis Summary",
storm_cells_overview="Storm Cells Overview:",
storm_cells_overview_1="The following pages show storm cell boundaries organized by day during the analysis period.",
storm_cells_overview_2="Each cell represents a storm system with defined boundaries and storm severity levels.",
storm_cells_summary="Storm Cells Summary:",
total_storm_cells="Total storm cells: {count}",
severity_breakdown="Severity breakdown:",
severity_cells="{severity}: {count} cells",
avg_direction="Average direction: {value:.1f}°",
avg_speed="Average speed: {value:.1f} km/h",
daily_storm_breakdown="Daily Storm Breakdown:",
daily_storm_item="{day}: {count} storm cells",
more_days="... and {n} more days",
complete_storm_list="Complete Storm Cells List:",
storm_cells="Storm Cells",
storm_cells_daily_viz="Daily storm cells visualization.",
detailed_lightning_data="Detailed Lightning Event Data",
appendix="Appendix",
risk_calc_method="1. Risk Calculation Method",
how_risk_determined="How Risk Scores Are Determined:",
risk_exposure_1="A turbine's risk score represents its exposure to cloud-to-ground lightning during the analysis period.",
risk_exposure_2="Each lightning strike contributes some risk based on distance to the turbine and the strike's current magnitude, and these contributions are summed.",
p0_desc="P0 (base factor): baseline scaling applied to every strike's contribution",
current_weight_desc="current_weight: controls how strongly current magnitude increases risk (larger = more weight on |I|)",
distance_desc="Distance (d): turbine-to-strike distance in kilometers",
current_desc="Current (|I|): absolute current magnitude |I| in amperes",
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)",
log_transformed="For visualization and reporting, we use the log-transformed score",
risk_interpretation="2. Risk Score Interpretation",
understanding_risk="Understanding Risk Score Values:",
min_risk_score="Minimum Risk Score: ~0.001 (far distance, low current)",
max_risk_score="Maximum Risk Score: ~2.500 (close distance, high current)",
typical_range="Typical Range: 0.010 - 1.000 for most lightning events",
risk_categories="Risk Score Categories (Fixed Color Intervals):",
risk_cat_very_low="Very Low Risk (<0.1): Blue - Distant lightning with low current",
risk_cat_low="Low Risk (0.1-0.2): Teal - Moderate distance lightning",
risk_cat_med_low="Med-Low Risk (0.2-0.4): Green - Closer lightning",
risk_cat_medium="Medium Risk (0.4-0.6): Yellow - Moderate risk lightning",
risk_cat_med_high="Med-High Risk (0.6-0.8): Orange - High risk lightning",
risk_cat_high="High Risk (0.8-1.0): Dark Orange - Very high risk lightning",
risk_cat_very_high="Very High Risk (1.0-1.2): Red - Extreme risk lightning",
risk_cat_critical="Critical Risk (>1.2): Dark Red - Critical risk lightning",
risk_chart="3. Risk Score Calculation Chart",
chart_reference="Chart Reference Guide:",
risk_chart_desc_1="The following chart shows how distance and current magnitude affect risk scores.",
risk_chart_desc_2="Use this chart to interpret the risk scores in the main report.",
risk_chart_red="Red areas = High risk (close distance, high current)",
risk_chart_yellow="Yellow/Orange areas = Medium risk",
risk_chart_blue="Blue/Green areas = Low risk (far distance, low current)",
centroid_rings="4. Centroid and Distance Ring Calculation",
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",
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.",
step_by_step="Step-by-Step Process:",
algo_chronological="Chronological Sorting: All lightning events are sorted by timestamp (local_time)",
algo_gap="Gap Calculation: Time differences between consecutive lightning events are calculated",
algo_period_boundary="Period Boundary Detection: When a gap between two consecutive events exceeds {gap} minutes, it marks the end of one period and the start of another",
algo_period_validation="Period Validation: Only periods with ≥{min_events} lightning events are considered significant",
algo_timespan="Timespan Definition: Start time = first event; End time = last event; Duration is actual time from first to last event",
algo_peak="Peak Sub-Period Detection: 3-minute rolling average; peaks when activity exceeds mean + 1 standard deviation; highlighted in yellow on histogram",
entln_title="6. EarthNetworks Total Lightning Network (ENTLN)",
entln_block_1="The lightning data presented in this report is sourced directly from the EarthNetworks Total Lightning Network (ENTLN), which monitors both in-cloud and cloud-to-ground strikes. ENTLN is the first in-cloud lightning and cloud-to-ground detection network deployed on a global basis. It includes more than 1,500 sensors (the world's largest) that incorporate advanced systems and technology to provide unmatched in-cloud and cloud-to-ground lightning detection total lightning detection.",
entln_block_2="A variety of government agencies, such as the National Weather Service, the Air Force Weather Agency, and organizations that include public safety and emergency response agencies, airports, and utilities rely on information from the ENTLN to make informed decisions.",
entln_block_3="Its unique capabilities detect long-range in-cloud lightning at high efficiencies, which are critical for the advanced prediction of severe weather such as:",
entln_tornadoes="Tornadoes and cyclones",
entln_rainfall="Heavy rainfall and monsoons",
entln_downburst="Downburst and wind shear",
entln_cg_strikes="Cloud-to-Ground lightning strikes",
entln_block_4="These potentially deadly weather events often occur within 5 to 30 minutes of in-cloud flash initiation. ENTLN has demonstrated the ability to significantly improve severe weather warning times over radar and other technologies by incorporating predictive capabilities that are crucial for characterizing severe storm precursors, improving severe storm warning lead times, and supporting comprehensive weather management planning.",
entln_block_5="The total lightning network has expanded to include more than 1,500 sensors in more than 40 countries around the world, including North and South America, the Caribbean, Europe, Australia, Africa and Asia. This dense sensor deployment enables high-efficiency capture of total lightning activity across these regions.",
risk_color_legend="Risk Color Legend (Log Risk):",
risk_legend_very_low="Very Low Risk (< 0.1)",
risk_legend_low="Low Risk (0.1 - 0.2)",
risk_legend_med_low="Med-Low Risk (0.2 - 0.4)",
risk_legend_medium="Medium Risk (0.4 - 0.6)",
risk_legend_med_high="Med-High Risk (0.6 - 0.8)",
risk_legend_high="High Risk (0.8 - 1.0)",
risk_legend_very_high="Very High Risk (1.0 - 1.2)",
risk_legend_critical="Critical Risk (1.2 - 1.4)",
risk_legend_maximum="Maximum Risk (≥ 1.4)",
col_turbine="Turbine",
col_lat="Lat",
col_lng="Lng",
col_unit_power_mwm="Unit Power (MWm)",
col_unit_power_mwe="Unit Power (MWe)",
col_tower_height="Tower Height (m)",
col_rotor_diameter="Rotor Diameter (m)",
col_altitude="Altitude (m)",
col_no="#",
col_time_local="Time (Local)",
col_current_amps="Current (amps)",
col_height_m="Height (m)",
col_lightning_type="Lightning Type",
col_proximity_km="Proximity (km)",
col_turbine_name="Turbine Name",
col_log_risk="Log Risk",
col_risk_definition="Risk Definition",
col_severity="Severity",
col_effective_time="Effective Time",
col_expire_time="Expire Time",
lightning_type_cg="cloud-to-ground",
lightning_type_ic="intercloud",
chart_time="Time",
chart_current="Current",
chart_distance="Distance",
chart_ring="Ring",
chart_distance_ring="Distance Ring",
chart_current_axis="Current (A)",
events_per_km2="events/km²",
events_per_km2_day="events/km²/day",
unknown="Unknown",
commentary_prefixes=("Gemini Commentary:", "Gemini commentary:", "Commentary:", "Commentary"),
gemini_write_turkish=False,
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_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",
hist_minutes_from_start="Minutes from start",
hist_lightning_count="Lightning Count",
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",
heatmap_xaxis="Lightning Current Magnitude (A)",
heatmap_yaxis="Distance from Turbine (km)",
heatmap_risk_level="Risk Level",
heatmap_risk_ticks=(
"Very Low: <0.1",
"Low: 0.1-0.2",
"Medium-Low: 0.2-0.4",
"Medium: 0.4-0.6",
"Medium-High: 0.6-0.8",
"High: 0.8-1.0",
"Very High: 1.0-1.2",
"Critical: >1.2",
),
storm_hover_severity="Severity",
storm_hover_effective="Effective",
storm_hover_expire="Expire",
storm_hover_direction="Direction",
storm_hover_speed="Speed",
)
_STRINGS_TR = ReportStrings(
cover_title="Yıldırım Aktivite Raporu",
analyzed_period="Analiz Dönemi:",
analyzed_period_local="Analiz Dönemi (yerel saat):",
centroid_coordinates="Merkez Koordinatları:",
latitude="Enlem",
longitude="Boylam",
report_generated="Rapor Oluşturulma",
cover_intro_1="Bu rapor, rüzgar çiftliği için kapsamlı yıldırım ve fırtına aktivitesi analizi sunar;",
cover_intro_2="operasyon ve güvenlik değerlendirmesi amacıyla hazırlanmıştır. Her bölüm, verileri anlamanıza",
cover_intro_3="ve türbin güvenliği hakkında kararlar almanıza yardımcı olacak açıklamalar içerir.",
wind_farm_default="Rüzgar Çiftliği",
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",
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",
intercloud_lightnings="Şimşek",
intercloud_current_vs_distance="Şimşek — Akım ve Mesafe",
ic_chart_title="Şimşek — Merkezden Akım ve Mesafe",
turbine_risk_assessment="Türbin Risk Değerlendirmesi",
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ı)",
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)",
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})",
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.",
ring_closer_risk="Yakın halkalardaki (0-{closest:.1f}km) yüksek sayılar, türbin operasyonları için artmış risk göstergesidir.",
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_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",
storm_cells_overview="Fırtına Hücreleri Genel Bakış:",
storm_cells_overview_1="Aşağıdaki sayfalar, analiz dönemi boyunca günlere göre düzenlenmiş fırtına hücresi sınırlarını gösterir.",
storm_cells_overview_2="Her hücre, tanımlı sınırları ve fırtına şiddet seviyeleri olan bir fırtına sistemini temsil eder.",
storm_cells_summary="Fırtına Hücreleri Özeti:",
total_storm_cells="Toplam fırtına hücresi: {count}",
severity_breakdown="Şiddet dağılımı:",
severity_cells="{severity}: {count} hücre",
avg_direction="Ortalama yön: {value:.1f}°",
avg_speed="Ortalama hız: {value:.1f} km/s",
daily_storm_breakdown="Günlük Fırtına Dağılımı:",
daily_storm_item="{day}: {count} fırtına hücresi",
more_days="... ve {n} gün daha",
complete_storm_list="Tam Fırtına Hücreleri Listesi:",
storm_cells="Fırtına Hücreleri",
storm_cells_daily_viz="Günlük fırtına hücreleri görselleştirmesi.",
detailed_lightning_data="Ayrıntılı Yıldırım Olay Verileri",
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",
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)",
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)",
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:",
min_risk_score="Minimum Risk Skoru: ~0.001 (uzak mesafe, düşük akım)",
max_risk_score="Maksimum Risk Skoru: ~2.500 (yakın mesafe, yüksek akım)",
typical_range="Tipik Aralık: çoğu yıldırım olayı için 0.010 - 1.000",
risk_categories="Risk Skoru Kategorileri (Sabit Renk Aralıkları):",
risk_cat_very_low="Çok Düşük Risk (<0.1): Mavi - Uzak, düşük akımlı yıldırım",
risk_cat_low="Düşük Risk (0.1-0.2): Turkuaz - Orta mesafeli yıldırım",
risk_cat_med_low="Orta-Düşük Risk (0.2-0.4): Yeşil - Daha yakın yıldırım",
risk_cat_medium="Orta Risk (0.4-0.6): Sarı - Orta riskli yıldırım",
risk_cat_med_high="Orta-Yüksek Risk (0.6-0.8): Turuncu - Yüksek riskli yıldırım",
risk_cat_high="Yüksek Risk (0.8-1.0): Koyu Turuncu - Çok yüksek riskli yıldırım",
risk_cat_very_high="Çok Yüksek Risk (1.0-1.2): Kırmızı - Aşırı riskli yıldırım",
risk_cat_critical="Kritik Risk (>1.2): Koyu Kırmızı - Kritik riskli yıldırım",
risk_chart="3. Risk Skoru Hesaplama Grafiği",
chart_reference="Grafik Referans Kılavuzu:",
risk_chart_desc_1="Aşağıdaki grafik, mesafe ve akım büyüklüğünün risk skorlarını nasıl etkilediğini gösterir.",
risk_chart_desc_2="Ana rapordaki risk skorlarını yorumlamak için bu grafiği kullanın.",
risk_chart_red="Kırmızı alanlar = Yüksek risk (yakın mesafe, yüksek akım)",
risk_chart_yellow="Sarı/Turuncu alanlar = Orta risk",
risk_chart_blue="Mavi/Yeşil alanlar = Düşük risk (uzak mesafe, düşük akım)",
centroid_rings="4. Merkez ve Mesafe Halkası Hesaplaması",
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",
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.",
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",
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.",
entln_block_3="Uzun menzilli şimşekleri yüksek verimle tespit etme yeteneği, şiddetli hava olaylarının ileri tahmini için kritiktir:",
entln_tornadoes="Tornado ve siklonlar",
entln_rainfall="Şiddetli yağış ve musonlar",
entln_downburst="Downburst ve rüzgar kesmesi",
entln_cg_strikes="Yıldırım darbeleri",
entln_block_4="Bu potansiyel olarak ölümcül hava olayları genellikle bulut içi flaş (şimşek) başlangıcından 5 ila 30 dakika içinde oluşur. ENTLN, radar ve diğer teknolojilere kıyasla şiddetli hava uyarı sürelerini önemli ölçüde iyileştirebilir.",
entln_block_5="Toplam yıldırım ağı dünyada 40'tan fazla ülkede 1.500'den fazla sensörle genişlemiştir. Bu yoğun sensör dağılımı, toplam yıldırım aktivitesinin yüksek verimle yakalanmasını sağlar.",
risk_color_legend="Risk Renk Efsanesi (Log Risk):",
risk_legend_very_low="Çok Düşük Risk (< 0.1)",
risk_legend_low="Düşük Risk (0.1 - 0.2)",
risk_legend_med_low="Orta-Düşük Risk (0.2 - 0.4)",
risk_legend_medium="Orta Risk (0.4 - 0.6)",
risk_legend_med_high="Orta-Yüksek Risk (0.6 - 0.8)",
risk_legend_high="Yüksek Risk (0.8 - 1.0)",
risk_legend_very_high="Çok Yüksek Risk (1.0 - 1.2)",
risk_legend_critical="Kritik Risk (1.2 - 1.4)",
risk_legend_maximum="Maksimum Risk (≥ 1.4)",
col_turbine="Türbin",
col_lat="Enlem",
col_lng="Boylam",
col_unit_power_mwm="Birim Güç (MWm)",
col_unit_power_mwe="Birim Güç (MWe)",
col_tower_height="Kule Yüksekliği (m)",
col_rotor_diameter="Rotor Çapı (m)",
col_altitude="Rakım (m)",
col_no="#",
col_time_local="Zaman (Yerel)",
col_current_amps="Akım (amper)",
col_height_m="Yükseklik (m)",
col_lightning_type="Yıldırım Türü",
col_proximity_km="Yakınlık (km)",
col_turbine_name="Türbin Adı",
col_log_risk="Log Risk",
col_risk_definition="Risk Tanımı",
col_severity="Şiddet",
col_effective_time="Geçerlilik Başlangıcı",
col_expire_time="Geçerlilik Bitişi",
lightning_type_cg="yıldırım",
lightning_type_ic="şimşek",
chart_time="Zaman",
chart_current="Akım",
chart_distance="Mesafe",
chart_ring="Halka",
chart_distance_ring="Mesafe Halkası",
chart_current_axis="Akım (A)",
events_per_km2="olay/km²",
events_per_km2_day="olay/km²/gün",
unknown="Bilinmiyor",
commentary_prefixes=("Gemini Yorumu:", "Gemini yorumu:", "Yorum:", "Yorum"),
gemini_write_turkish=True,
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_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",
hist_lightning_count="Yıldırım Sayısı",
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",
heatmap_xaxis="Yıldırım Akım Büyüklüğü (A)",
heatmap_yaxis="Türbinden Mesafe (km)",
heatmap_risk_level="Risk Seviyesi",
heatmap_risk_ticks=(
"Çok Düşük: <0.1",
"Düşük: 0.1-0.2",
"Orta-Düşük: 0.2-0.4",
"Orta: 0.4-0.6",
"Orta-Yüksek: 0.6-0.8",
"Yüksek: 0.8-1.0",
"Çok Yüksek: 1.0-1.2",
"Kritik: >1.2",
),
storm_hover_severity="Şiddet",
storm_hover_effective="Başlangıç",
storm_hover_expire="Bitiş",
storm_hover_direction="Yön",
storm_hover_speed="Hız",
)
_RISK_DEFINITIONS_EN = (
"Very Low Risk",
"Low Risk",
"Med-Low Risk",
"Medium Risk",
"Med-High Risk",
"High Risk",
"Very High Risk",
"Critical Risk",
"Maximum Risk",
)
_RISK_DEFINITIONS_TR = (
"Çok Düşük Risk",
"Düşük Risk",
"Orta-Düşük Risk",
"Orta Risk",
"Orta-Yüksek Risk",
"Yüksek Risk",
"Çok Yüksek Risk",
"Kritik Risk",
"Maksimum Risk",
)
_STRINGS_BY_LANG: dict[ReportLanguage, ReportStrings] = {
"en": _STRINGS_EN,
"tr": _STRINGS_TR,
}
def get_strings(lang: str | ReportLanguage | None = None) -> ReportStrings:
if lang is None:
lang = get_report_language()
return _STRINGS_BY_LANG[normalize_language(lang if isinstance(lang, str) else lang)]
def get_risk_definition(risk_log_value: float, lang: str | ReportLanguage | None = None) -> str:
labels = _RISK_DEFINITIONS_TR if normalize_language(lang) == "tr" else _RISK_DEFINITIONS_EN
thresholds = (0.1, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4)
for idx, threshold in enumerate(thresholds):
if risk_log_value < threshold:
return labels[idx]
return labels[-1]

View File

@ -168,25 +168,14 @@ def get_turbine_color_by_fixed_intervals(risk_log_value: float) -> str:
else: else:
return '#B71C1C' return '#B71C1C'
def get_risk_definition_by_fixed_intervals(risk_log_value: float) -> str: def get_risk_definition_by_fixed_intervals(
if risk_log_value < 0.1: risk_log_value: float,
return 'Very Low Risk' language: str | None = None,
elif risk_log_value < 0.2: ) -> str:
return 'Low Risk' from src.reporting.strings import get_risk_definition, get_report_language
elif risk_log_value < 0.4:
return 'Med-Low Risk' lang = language if language is not None else get_report_language()
elif risk_log_value < 0.6: return get_risk_definition(risk_log_value, lang)
return 'Medium Risk'
elif risk_log_value < 0.8:
return 'Med-High Risk'
elif risk_log_value < 1.0:
return 'High Risk'
elif risk_log_value < 1.2:
return 'Very High Risk'
elif risk_log_value < 1.4:
return 'Critical Risk'
else:
return 'Maximum Risk'
def get_turbine_colors_by_fixed_intervals(risk_log_values: List[float]) -> List[str]: def get_turbine_colors_by_fixed_intervals(risk_log_values: List[float]) -> List[str]:
""" """

View File

@ -3,6 +3,7 @@ import plotly.express as px
import numpy as np import numpy as np
from src.analysis.geospatial import create_circle_points, haversine_distance from src.analysis.geospatial import create_circle_points, haversine_distance
from src.config import config from src.config import config
from src.reporting.strings import ReportLanguage, get_report_language, get_strings
from src.visualization.basemap import add_satellite_basemap from src.visualization.basemap import add_satellite_basemap
import pandas as pd import pandas as pd
@ -478,8 +479,9 @@ def plot_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.DataFrame, tu
return fig return fig
def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame, storm_data: list = None, precomputed: dict | None = None) -> go.Figure: def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame, storm_data: list = None, precomputed: dict | None = None, lang: ReportLanguage | None = None) -> go.Figure:
"""Create a coordinate plane showing only intercloud lightning strikes.""" """Create a coordinate plane showing only intercloud lightning strikes."""
s = get_strings(lang or get_report_language())
turbine_lat = turbine_row['lat'] turbine_lat = turbine_row['lat']
turbine_lon = turbine_row['lng'] turbine_lon = turbine_row['lng']
@ -495,7 +497,7 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da
mode='lines', mode='lines',
line=dict(color=color, width=COORDINATE_PLANE_RING_LINE_WIDTH), line=dict(color=color, width=COORDINATE_PLANE_RING_LINE_WIDTH),
opacity=0.6, opacity=0.6,
name=f'{radius/1000:.1f}km Distance Ring', name=s.map_distance_ring.format(radius=radius / 1000),
showlegend=True showlegend=True
)) ))
@ -521,7 +523,7 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da
text=turbine_df['name'].tolist(), text=turbine_df['name'].tolist(),
textfont=dict(size=24, color='black'), textfont=dict(size=24, color='black'),
textposition='middle center', textposition='middle center',
name='Wind Turbines', name=s.map_wind_turbines,
showlegend=True showlegend=True
)) ))
@ -544,7 +546,7 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da
text=['S'] * len(storm_df), text=['S'] * len(storm_df),
textfont=dict(size=24, color='white'), textfont=dict(size=24, color='white'),
textposition='middle center', textposition='middle center',
name='Storm Cells', name=s.map_storm_cells,
showlegend=True showlegend=True
)) ))
@ -591,7 +593,7 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da
sizemin=COORDINATE_PLANE_LIGHTNING_SIZE_MIN, sizemin=COORDINATE_PLANE_LIGHTNING_SIZE_MIN,
line=dict(width=1, color='white'), line=dict(width=1, color='white'),
), ),
name='Intercloud Lightning', name=s.map_ic_lightning,
showlegend=True showlegend=True
)) ))
@ -606,9 +608,9 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da
add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max) add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max)
fig.update_layout( fig.update_layout(
title=f'Intercloud Lightning - Coordinate Plane View - Central Turbine: {turbine_row["name"]}', title=s.map_ic_plane_title.format(name=turbine_row["name"]),
xaxis=dict( xaxis=dict(
title=dict(text='Longitude', font=dict(size=28)), # x-axis title font size 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 tickfont=dict(size=22), # x-axis tick label font size
range=[lon_min, lon_max], range=[lon_min, lon_max],
showgrid=False, showgrid=False,
@ -617,7 +619,7 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da
zeroline=False zeroline=False
), ),
yaxis=dict( yaxis=dict(
title=dict(text='Latitude', font=dict(size=28)), # y-axis title font size 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 tickfont=dict(size=22), # y-axis tick label font size
range=[lat_min, lat_max], range=[lat_min, lat_max],
showgrid=False, showgrid=False,
@ -629,7 +631,7 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da
paper_bgcolor='white', paper_bgcolor='white',
showlegend=True, showlegend=True,
legend=dict( legend=dict(
title=dict(text='Legend', font=dict(size=24)), # legend title font size title=dict(text=s.map_legend, font=dict(size=24)), # legend title font size
font=dict(size=20), # legend item font size font=dict(size=20), # legend item font size
orientation='h', orientation='h',
x=0.5, x=0.5,
@ -650,8 +652,9 @@ def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.Da
return fig return fig
def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame, storm_data: list = None, precomputed: dict | None = None) -> go.Figure: def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame, storm_data: list = None, precomputed: dict | None = None, lang: ReportLanguage | None = None) -> go.Figure:
"""Create a coordinate plane showing only cloud-to-ground lightning strikes.""" """Create a coordinate plane showing only cloud-to-ground lightning strikes."""
s = get_strings(lang or get_report_language())
turbine_lat = turbine_row['lat'] turbine_lat = turbine_row['lat']
turbine_lon = turbine_row['lng'] turbine_lon = turbine_row['lng']
@ -667,7 +670,7 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df:
mode='lines', mode='lines',
line=dict(color=color, width=COORDINATE_PLANE_RING_LINE_WIDTH), line=dict(color=color, width=COORDINATE_PLANE_RING_LINE_WIDTH),
opacity=0.6, opacity=0.6,
name=f'{radius/1000:.1f}km Distance Ring', name=s.map_distance_ring.format(radius=radius / 1000),
showlegend=True showlegend=True
)) ))
@ -693,7 +696,7 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df:
text=turbine_df['name'].tolist(), text=turbine_df['name'].tolist(),
textfont=dict(size=18, color='black'), # turbine name label font size textfont=dict(size=18, color='black'), # turbine name label font size
textposition='middle center', textposition='middle center',
name='Wind Turbines', name=s.map_wind_turbines,
showlegend=True showlegend=True
)) ))
@ -716,7 +719,7 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df:
text=['S'] * len(storm_df), text=['S'] * len(storm_df),
textfont=dict(size=18, color='white'), # storm "S" label font size textfont=dict(size=18, color='white'), # storm "S" label font size
textposition='middle center', textposition='middle center',
name='Storm Cells', name=s.map_storm_cells,
showlegend=True showlegend=True
)) ))
@ -763,7 +766,7 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df:
sizemin=COORDINATE_PLANE_LIGHTNING_SIZE_MIN, sizemin=COORDINATE_PLANE_LIGHTNING_SIZE_MIN,
line=dict(width=1, color='white'), line=dict(width=1, color='white'),
), ),
name='Cloud-to-Ground Lightning', name=s.map_cg_lightning,
showlegend=True showlegend=True
)) ))
@ -778,9 +781,9 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df:
add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max) add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max)
fig.update_layout( fig.update_layout(
title=f'Cloud-to-Ground Lightning - Coordinate Plane View - Central Turbine: {turbine_row["name"]}', title=s.map_cg_plane_title.format(name=turbine_row["name"]),
xaxis=dict( xaxis=dict(
title=dict(text='Longitude', font=dict(size=28)), # x-axis title font size 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 tickfont=dict(size=22), # x-axis tick label font size
range=[lon_min, lon_max], range=[lon_min, lon_max],
showgrid=False, showgrid=False,
@ -789,7 +792,7 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df:
zeroline=False zeroline=False
), ),
yaxis=dict( yaxis=dict(
title=dict(text='Latitude', font=dict(size=28)), # y-axis title font size 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 tickfont=dict(size=22), # y-axis tick label font size
range=[lat_min, lat_max], range=[lat_min, lat_max],
showgrid=False, showgrid=False,
@ -801,7 +804,7 @@ def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df:
paper_bgcolor='white', paper_bgcolor='white',
showlegend=True, showlegend=True,
legend=dict( legend=dict(
title=dict(text='Legend', font=dict(size=24)), # legend title font size title=dict(text=s.map_legend, font=dict(size=24)), # legend title font size
font=dict(size=20), # legend item font size font=dict(size=20), # legend item font size
orientation='h', orientation='h',
x=0.5, x=0.5,
@ -911,13 +914,15 @@ def create_risk_score_chart() -> go.Figure:
return fig return fig
def create_risk_score_heatmap() -> go.Figure: def create_risk_score_heatmap(lang: ReportLanguage | None = None) -> go.Figure:
""" """
Create a 2D heatmap showing risk scores for different distance and current combinations. Create a 2D heatmap showing risk scores for different distance and current combinations.
This provides a clearer view of the risk calculation relationship. This provides a clearer view of the risk calculation relationship.
""" """
import numpy as np import numpy as np
from src.config import config from src.config import config
s = get_strings(lang or get_report_language())
# Get risk parameters # Get risk parameters
P_0 = config.risk_params['P_0'] P_0 = config.risk_params['P_0']
@ -962,10 +967,10 @@ def create_risk_score_heatmap() -> go.Figure:
y=distances_km, y=distances_km,
colorscale=custom_colorscale, colorscale=custom_colorscale,
colorbar=dict( colorbar=dict(
title="Risk Level", title=s.heatmap_risk_level,
tickmode='array', tickmode='array',
tickvals=[0.01, 0.13, 0.26, 0.4, 0.53, 0.66, 0.8, 0.93], tickvals=[0.01, 0.13, 0.26, 0.4, 0.53, 0.66, 0.8, 0.93],
ticktext=['Very Low: <0.1', 'Low: 0.1-0.2', 'Medium-Low: 0.2-0.4', 'Medium: 0.4-0.6', 'Medium-High: 0.6-0.8', 'High: 0.8-1.0', 'Very High: 1.0-1.2', 'Critical: >1.2'], ticktext=list(s.heatmap_risk_ticks),
len=1.0, len=1.0,
y=0.5, y=0.5,
yanchor='middle', yanchor='middle',
@ -1007,13 +1012,13 @@ def create_risk_score_heatmap() -> go.Figure:
# Update layout # Update layout
fig.update_layout( fig.update_layout(
title={ title={
'text': f'Risk Score Heatmap<br><sub>Current Magnitude vs Distance (0.1-{max_distance_km:.1f}km) - Higher values (red) = Higher risk</sub>', 'text': f'{s.heatmap_title}<br><sub>{s.heatmap_subtitle.format(max_km=max_distance_km)}</sub>',
'x': 0.5, 'x': 0.5,
'xanchor': 'center', 'xanchor': 'center',
'font': {'size': 24} # title font size 'font': {'size': 24} # title font size
}, },
xaxis_title='Lightning Current Magnitude (A)', xaxis_title=s.heatmap_xaxis,
yaxis_title='Distance from Turbine (km)', yaxis_title=s.heatmap_yaxis,
xaxis=dict( xaxis=dict(
tickmode='linear', tickmode='linear',
tick0=0, tick0=0,

View File

@ -10,6 +10,7 @@ from collections import defaultdict
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from src.analysis.geospatial import haversine_distance from src.analysis.geospatial import haversine_distance
from src.config import config from src.config import config
from src.reporting.strings import ReportLanguage, get_report_language, get_strings
from src.utils import parse_period_string_to_datetime from src.utils import parse_period_string_to_datetime
from src.visualization.basemap import add_satellite_basemap from src.visualization.basemap import add_satellite_basemap
@ -117,7 +118,8 @@ def group_storm_data_by_month(storm_data: List[Dict]) -> Dict[str, List[Dict]]:
return dict(monthly_storms) return dict(monthly_storms)
def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.DataFrame = None, center_lat: float = None, center_lon: float = None) -> go.Figure: def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.DataFrame = None, center_lat: float = None, center_lon: float = None, lang: ReportLanguage | None = None) -> go.Figure:
s = get_strings(lang or get_report_language())
""" """
Create a coordinate plane visualization showing storm cells with turbines and distance rings. Create a coordinate plane visualization showing storm cells with turbines and distance rings.
@ -256,11 +258,11 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D
showlegend=False, # Don't show individual storms in legend showlegend=False, # Don't show individual storms in legend
hovertemplate=( hovertemplate=(
f"<b>Storm Cell {i+1}</b><br>" f"<b>Storm Cell {i+1}</b><br>"
f"Severity: {storm.get('lightning_severity', 'Unknown')}<br>" f"{s.storm_hover_severity}: {storm.get('lightning_severity', s.unknown)}<br>"
f"Effective: {format_datetime_for_display(storm.get('effective_time', 'N/A'))}<br>" f"{s.storm_hover_effective}: {format_datetime_for_display(storm.get('effective_time', 'N/A'))}<br>"
f"Expire: {format_datetime_for_display(storm.get('expire_time', 'N/A'))}<br>" f"{s.storm_hover_expire}: {format_datetime_for_display(storm.get('expire_time', 'N/A'))}<br>"
f"Direction: {storm.get('direction', 'N/A')}°<br>" f"{s.storm_hover_direction}: {storm.get('direction', 'N/A')}°<br>"
f"Speed: {storm.get('speed', 'N/A')} km/h<br>" f"{s.storm_hover_speed}: {storm.get('speed', 'N/A')} km/h<br>"
f"<extra></extra>" f"<extra></extra>"
) )
)) ))
@ -289,9 +291,9 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D
fig.update_layout( fig.update_layout(
font=dict(size=18), font=dict(size=18),
title=dict(text='Storm Cells - Coordinate Plane View', font=dict(size=28)), title=dict(text=s.storm_cells, font=dict(size=28)),
xaxis_title='Longitude', xaxis_title=s.map_longitude,
yaxis_title='Latitude', yaxis_title=s.map_latitude,
xaxis=dict( xaxis=dict(
range=[lon_min, lon_max], range=[lon_min, lon_max],
showgrid=False, showgrid=False,
@ -314,7 +316,7 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D
paper_bgcolor='white', paper_bgcolor='white',
showlegend=True, showlegend=True,
legend=dict( legend=dict(
title=dict(text='Legend', font=dict(size=24)), title=dict(text=s.map_legend, font=dict(size=24)),
font=dict(size=20), font=dict(size=20),
orientation='h', orientation='h',
x=0.5, x=0.5,
@ -332,7 +334,7 @@ def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.D
return fig return fig
def create_storm_cells_map(storm_data: List[Dict], turbine_df: pd.DataFrame = None, center_lat: float = None, center_lon: float = None) -> go.Figure: def create_storm_cells_map(storm_data: List[Dict], turbine_df: pd.DataFrame = None, center_lat: float = None, center_lon: float = None, lang: ReportLanguage | None = None) -> go.Figure:
""" """
Create a map showing storm cells from the fırtına data with turbines and distance rings. Create a map showing storm cells from the fırtına data with turbines and distance rings.
Now uses coordinate plane view instead of mapbox. Now uses coordinate plane view instead of mapbox.
@ -346,7 +348,7 @@ def create_storm_cells_map(storm_data: List[Dict], turbine_df: pd.DataFrame = No
Returns: Returns:
Plotly figure with storm cells, turbines, and distance rings in coordinate plane view Plotly figure with storm cells, turbines, and distance rings in coordinate plane view
""" """
return create_storm_cells_coordinate_plane(storm_data, turbine_df, center_lat, center_lon) return create_storm_cells_coordinate_plane(storm_data, turbine_df, center_lat, center_lon, lang=lang)
def create_daily_storm_maps(storm_data: List[Dict], max_maps_per_page: int = 2) -> List[go.Figure]: def create_daily_storm_maps(storm_data: List[Dict], max_maps_per_page: int = 2) -> List[go.Figure]:
""" """
@ -400,7 +402,7 @@ def create_daily_storm_maps(storm_data: List[Dict], max_maps_per_page: int = 2)
# Add day title # Add day title
day_fig.update_layout( day_fig.update_layout(
title=f"Storm Cells - {day}", title=get_strings(get_report_language()).storm_cells_day_title.format(day=day),
title_x=0.5, title_x=0.5,
title_font_size=16 title_font_size=16
) )