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:
parent
17c9aa865a
commit
40f554d190
42
README.md
42
README.md
@ -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). n8n’s 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`).
|
||||||
|
|
||||||
|
|||||||
@ -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()`."""
|
||||||
|
|||||||
@ -3,7 +3,10 @@ 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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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 turbine’s 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 strike’s 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 strike’s 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):
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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ı \"X–Y 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ı açı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:43–03-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,0–3,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,52 +235,84 @@ 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)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if s.gemini_write_turkish:
|
||||||
|
radius_txt = (
|
||||||
|
f"{analysis_radius_km:.1f} km yarıçaplı analiz alanında"
|
||||||
|
if isinstance(analysis_radius_km, (int, float))
|
||||||
|
else "analiz alanında"
|
||||||
|
)
|
||||||
|
events_txt = f"toplam {total_events} yıldırım olayı" if total_events is not None else "yıldırım olayı"
|
||||||
|
ring_sentence = _build_ring_distribution_sentence_tr(best_ring, outermost_ring, s)
|
||||||
|
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:
|
||||||
radius_txt = f"within {analysis_radius_km:.1f} km" if isinstance(analysis_radius_km, (int, float)) else ""
|
radius_txt = f"within {analysis_radius_km:.1f} km" if isinstance(analysis_radius_km, (int, float)) else ""
|
||||||
events_txt = f"{total_events} total lightning events" if total_events is not None else "N/A"
|
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 = (
|
paragraph_intro = (
|
||||||
f"For {analysis_period}, the dataset contains {events_txt} {radius_txt}, corresponding to an overall lightning density of {density_txt}. "
|
f"For {analysis_period}, the dataset contains {events_txt} {radius_txt}, corresponding to an overall lightning density of {density_txt}. "
|
||||||
f"The largest contributions are concentrated in {best_ring_txt}, with additional activity also present in {outer_ring_txt}. "
|
f"{ring_sentence} "
|
||||||
)
|
)
|
||||||
|
|
||||||
turbine_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. "
|
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
|
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. "
|
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 = (
|
method_sentence = (
|
||||||
f"This indicates the turbine was exposed to a combination of closer cloud-to-ground strikes and stronger current magnitudes. "
|
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). "
|
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:
|
if storm_over_turbine:
|
||||||
storm_interaction_sentence = (
|
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}). "
|
f"Storm interaction: storm-cell centroids came within {storm_over_threshold_km:.1f} km of the turbine (count={storm_near_turbine_count}). "
|
||||||
@ -169,20 +323,19 @@ def fallback_commentary(context: dict[str, Any]) -> str:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
storm_interaction_sentence = ""
|
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."
|
||||||
paragraph = (paragraph_intro + turbine_sentence + method_sentence + storm_interaction_sentence + storm_severity_sentence).strip()
|
|
||||||
return paragraph
|
return (paragraph_intro + turbine_sentence + method_sentence + storm_interaction_sentence + storm_severity_sentence).strip()
|
||||||
|
|
||||||
|
|
||||||
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
643
src/reporting/strings.py
Normal 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]
|
||||||
27
src/utils.py
27
src/utils.py
@ -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]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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,7 +914,7 @@ 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.
|
||||||
@ -919,6 +922,8 @@ def create_risk_score_heatmap() -> go.Figure:
|
|||||||
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']
|
||||||
alpha = config.risk_params['alpha']
|
alpha = config.risk_params['alpha']
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user