Add functions for handling UTF-8 encoding and attachment headers in report_service; enhance chart time parsing in docx_sections; improve report generation logic in docx.py to conditionally include charts for Cloud-to-Ground and Intercloud lightnings based on data availability.

This commit is contained in:
erdemerikci 2026-05-07 15:32:04 +03:00
parent 444f3f484c
commit e5b211e9e5
4 changed files with 1911 additions and 59 deletions

File diff suppressed because it is too large Load Diff

View File

@ -87,6 +87,31 @@ def _build_filename(payload: dict[str, Any]) -> str:
return "_".join(parts)
def _pct_encode_utf8(text: str) -> str:
return "".join(f"%{b:02X}" for b in text.encode("utf-8"))
def _latin1_header_values(headers: dict[str, str]) -> dict[str, str]:
return {
k: v.encode("latin-1", errors="replace").decode("latin-1") for k, v in headers.items()
}
def _ascii_attachment_filename_from_stem(stem: str) -> str:
slug = slugify_ascii_underscore(stem)
safe = slug.encode("ascii", "ignore").decode("ascii").strip("._-") or "report"
return f"{safe}.docx"
def _content_disposition_attachment(filename: str) -> str:
lower = filename.lower()
stem = filename[: -len(".docx")] if lower.endswith(".docx") else filename
ascii_fn = _ascii_attachment_filename_from_stem(stem)
ascii_fn = "".join(ch for ch in ascii_fn if ord(ch) < 128) or "report.docx"
encoded = _pct_encode_utf8(filename)
return f'attachment; filename="{ascii_fn}"; filename*=UTF-8\'\'{encoded}'
@app.get("/health")
def health() -> JSONResponse:
return JSONResponse({"ok": True, "service": "lightning-report", "version": app.version})
@ -147,13 +172,20 @@ async def generate(request: Request) -> Response:
pass
logger.info("Generated %s (%d bytes) for %s", filename, len(data), customer_name)
stem = filename[: -len(".docx")] if filename.lower().endswith(".docx") else filename
ascii_filename = _ascii_attachment_filename_from_stem(stem)
ascii_filename = "".join(ch for ch in ascii_filename if ord(ch) < 128) or "report.docx"
customer_hdr = _pct_encode_utf8(str(customer_name))
hdrs = _latin1_header_values(
{
"Content-Disposition": _content_disposition_attachment(filename),
"X-Report-Filename": ascii_filename,
"X-Report-Customer": customer_hdr,
"X-Report-Strikes": str(n_strikes),
}
)
return Response(
content=data,
media_type=DOCX_MIME,
headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"X-Report-Filename": filename,
"X-Report-Customer": str(customer_name),
"X-Report-Strikes": str(n_strikes),
},
headers=hdrs,
)

View File

@ -682,7 +682,12 @@ def create_docx_report(
"name": config.wind_farm_name or "Wind Farm",
})
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)
mask_within = pre["mask_within"]
has_cg = bool(((type_values == "0") & mask_within).any())
has_ic = bool(((type_values != "0") & mask_within).any())
if has_cg:
_add_title(doc, "Cloud-to-Ground Lightnings", size_pt=14)
if start_date and end_date:
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10)
@ -706,6 +711,7 @@ def create_docx_report(
_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()
if has_ic:
_add_title(doc, "Intercloud Lightnings", size_pt=14)
if start_date and end_date:
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10)

View File

@ -8,6 +8,38 @@ from src.config import config
from src.reporting.precompute import precompute_distances_and_rings
def _parse_chart_times(values: pd.Series) -> pd.Series:
def _to_local_naive(ts: pd.Series) -> pd.Series:
if config.timezone:
try:
ts = ts.dt.tz_convert(config.timezone)
except Exception:
pass
try:
return ts.dt.tz_localize(None)
except Exception:
return ts
numeric = pd.to_numeric(values, errors="coerce")
numeric_valid = numeric.dropna()
if len(numeric_valid) > 0 and len(numeric_valid) >= max(1, int(len(values) * 0.7)):
abs_max = float(numeric_valid.abs().max())
if abs_max >= 1e17:
unit = "ns"
elif abs_max >= 1e14:
unit = "us"
elif abs_max >= 1e11:
unit = "ms"
else:
unit = "s"
parsed = pd.to_datetime(numeric, errors="coerce", unit=unit, utc=True)
return _to_local_naive(parsed)
parsed = pd.to_datetime(values, errors="coerce", utc=True)
return _to_local_naive(parsed)
def _build_current_vs_distance_chart(
lightning_df: pd.DataFrame,
dists_km: np.ndarray,
@ -30,8 +62,19 @@ def _build_current_vs_distance_chart(
distances = dists_km[combined_mask]
currents = subset["current"].values.astype(float)
time_series = pd.to_datetime(subset["local_time"])
time_dt = time_series.sort_values()
time_series = _parse_chart_times(subset["local_time"])
valid_mask = ~time_series.isna()
if valid_mask.sum() == 0:
return None
time_series = time_series.loc[valid_mask]
distances = distances[valid_mask.values]
currents = currents[valid_mask.values]
sort_idx = np.argsort(time_series.values.astype("datetime64[ns]"))
time_values = time_series.values[sort_idx]
distances = distances[sort_idx]
currents = currents[sort_idx]
rings_km = np.array(config.distance_rings, dtype=float) / 1000.0
ring_colors_cfg = getattr(config, "ring_colors", None) or []
@ -45,21 +88,18 @@ def _build_current_vs_distance_chart(
else:
ring_names.append(f"{rings_km[i - 1]:.1f}-{rings_km[i]:.1f} km")
t_min = time_dt.min()
t_max = time_dt.max()
tick_vals = pd.date_range(t_min, t_max, periods=4)
tick_text = [t.strftime("%d-%m-%Y %H:%M") for t in tick_vals]
fig = go.Figure()
for i in range(len(rings_km)):
mask_ring = ring_indices == i
if mask_ring.sum() == 0:
continue
color = ring_colors_cfg[i] if i < len(ring_colors_cfg) else "gray"
r_times = time_series.values[mask_ring]
r_times = time_values[mask_ring]
r_currents = currents[mask_ring]
r_dists = distances[mask_ring]
tz_suffix = f" ({config.timezone})" if config.timezone else ""
r_time_labels = pd.to_datetime(r_times).strftime("%d-%m-%Y %H:%M").values
r_time_labels = np.array([f"{t}{tz_suffix}" for t in r_time_labels])
fig.add_trace(
go.Scatter(
x=r_times,
@ -78,19 +118,22 @@ def _build_current_vs_distance_chart(
)
)
timezone_label = config.timezone or "UTC"
fig.update_layout(
font=dict(size=16),
title=dict(text=title, x=0.5, font=dict(size=22)),
xaxis_title="Time",
xaxis_title=f"Time ({timezone_label})",
yaxis_title="Current (A)",
plot_bgcolor="white",
paper_bgcolor="white",
xaxis=dict(
type="date",
showgrid=True,
gridcolor="lightgray",
zeroline=False,
tickvals=tick_vals,
ticktext=tick_text,
tickformat="%d-%m-%Y %H:%M",
nticks=6,
tickangle=-25,
tickfont=dict(size=22),
title_font=dict(size=28),