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:
parent
444f3f484c
commit
e5b211e9e5
1771
Lightning_Report_Automatic.json
Normal file
1771
Lightning_Report_Automatic.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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,
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user