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)
|
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")
|
@app.get("/health")
|
||||||
def health() -> JSONResponse:
|
def health() -> JSONResponse:
|
||||||
return JSONResponse({"ok": True, "service": "lightning-report", "version": app.version})
|
return JSONResponse({"ok": True, "service": "lightning-report", "version": app.version})
|
||||||
@ -147,13 +172,20 @@ async def generate(request: Request) -> Response:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info("Generated %s (%d bytes) for %s", filename, len(data), customer_name)
|
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(
|
return Response(
|
||||||
content=data,
|
content=data,
|
||||||
media_type=DOCX_MIME,
|
media_type=DOCX_MIME,
|
||||||
headers={
|
headers=hdrs,
|
||||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
|
||||||
"X-Report-Filename": filename,
|
|
||||||
"X-Report-Customer": str(customer_name),
|
|
||||||
"X-Report-Strikes": str(n_strikes),
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -682,52 +682,58 @@ def create_docx_report(
|
|||||||
"name": config.wind_farm_name or "Wind Farm",
|
"name": config.wind_farm_name or "Wind Farm",
|
||||||
})
|
})
|
||||||
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)
|
||||||
|
mask_within = pre["mask_within"]
|
||||||
|
has_cg = bool(((type_values == "0") & mask_within).any())
|
||||||
|
has_ic = bool(((type_values != "0") & mask_within).any())
|
||||||
|
|
||||||
_add_title(doc, "Cloud-to-Ground Lightnings", size_pt=14)
|
if has_cg:
|
||||||
if start_date and end_date:
|
_add_title(doc, "Cloud-to-Ground Lightnings", size_pt=14)
|
||||||
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10)
|
if start_date and end_date:
|
||||||
cg_fig = plot_cloud_to_ground_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre)
|
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10)
|
||||||
_add_image_from_bytes(doc, cg_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width)
|
cg_fig = plot_cloud_to_ground_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre)
|
||||||
doc.add_page_break()
|
_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()
|
||||||
|
|
||||||
_add_title(doc, "Cloud-to-Ground — Current vs Distance", size_pt=14)
|
_add_title(doc, "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(
|
||||||
lightning_df,
|
lightning_df,
|
||||||
pre["dists_km"],
|
pre["dists_km"],
|
||||||
pre["mask_within"],
|
pre["mask_within"],
|
||||||
"cg",
|
"cg",
|
||||||
"Cloud-to-Ground Lightning — Current vs Distance from Centroid",
|
"Cloud-to-Ground Lightning — Current vs Distance from Centroid",
|
||||||
900,
|
900,
|
||||||
650,
|
650,
|
||||||
)
|
)
|
||||||
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()
|
||||||
|
|
||||||
_add_title(doc, "Intercloud Lightnings", size_pt=14)
|
if has_ic:
|
||||||
if start_date and end_date:
|
_add_title(doc, "Intercloud Lightnings", size_pt=14)
|
||||||
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10)
|
if start_date and end_date:
|
||||||
ic_fig = plot_intercloud_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre)
|
_add_paragraph(doc, f"{start_date} - {end_date}", size_pt=10)
|
||||||
_add_image_from_bytes(doc, ic_fig.to_image(format="png", width=1200, height=900, scale=2, engine="kaleido"), content_width)
|
ic_fig = plot_intercloud_coordinate_plane(centroid_row, lightning_df, turbine_df, storm_data, precomputed=pre)
|
||||||
doc.add_page_break()
|
_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()
|
||||||
|
|
||||||
_add_title(doc, "Intercloud — Current vs Distance", size_pt=14)
|
_add_title(doc, "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(
|
||||||
lightning_df,
|
lightning_df,
|
||||||
pre["dists_km"],
|
pre["dists_km"],
|
||||||
pre["mask_within"],
|
pre["mask_within"],
|
||||||
"ic",
|
"ic",
|
||||||
"Intercloud Lightning — Current vs Distance from Centroid",
|
"Intercloud Lightning — Current vs Distance from Centroid",
|
||||||
900,
|
900,
|
||||||
650,
|
650,
|
||||||
)
|
)
|
||||||
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)
|
||||||
if risk_table_data and len(risk_table_data) > 1:
|
if risk_table_data and len(risk_table_data) > 1:
|
||||||
|
|||||||
@ -8,6 +8,38 @@ from src.config import config
|
|||||||
from src.reporting.precompute import precompute_distances_and_rings
|
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(
|
def _build_current_vs_distance_chart(
|
||||||
lightning_df: pd.DataFrame,
|
lightning_df: pd.DataFrame,
|
||||||
dists_km: np.ndarray,
|
dists_km: np.ndarray,
|
||||||
@ -30,8 +62,19 @@ def _build_current_vs_distance_chart(
|
|||||||
distances = dists_km[combined_mask]
|
distances = dists_km[combined_mask]
|
||||||
currents = subset["current"].values.astype(float)
|
currents = subset["current"].values.astype(float)
|
||||||
|
|
||||||
time_series = pd.to_datetime(subset["local_time"])
|
time_series = _parse_chart_times(subset["local_time"])
|
||||||
time_dt = time_series.sort_values()
|
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
|
rings_km = np.array(config.distance_rings, dtype=float) / 1000.0
|
||||||
ring_colors_cfg = getattr(config, "ring_colors", None) or []
|
ring_colors_cfg = getattr(config, "ring_colors", None) or []
|
||||||
@ -45,21 +88,18 @@ def _build_current_vs_distance_chart(
|
|||||||
else:
|
else:
|
||||||
ring_names.append(f"{rings_km[i - 1]:.1f}-{rings_km[i]:.1f} km")
|
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()
|
fig = go.Figure()
|
||||||
for i in range(len(rings_km)):
|
for i in range(len(rings_km)):
|
||||||
mask_ring = ring_indices == i
|
mask_ring = ring_indices == i
|
||||||
if mask_ring.sum() == 0:
|
if mask_ring.sum() == 0:
|
||||||
continue
|
continue
|
||||||
color = ring_colors_cfg[i] if i < len(ring_colors_cfg) else "gray"
|
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_currents = currents[mask_ring]
|
||||||
r_dists = distances[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 = 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(
|
fig.add_trace(
|
||||||
go.Scatter(
|
go.Scatter(
|
||||||
x=r_times,
|
x=r_times,
|
||||||
@ -78,19 +118,22 @@ def _build_current_vs_distance_chart(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
timezone_label = config.timezone or "UTC"
|
||||||
|
|
||||||
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="Time",
|
xaxis_title=f"Time ({timezone_label})",
|
||||||
yaxis_title="Current (A)",
|
yaxis_title="Current (A)",
|
||||||
plot_bgcolor="white",
|
plot_bgcolor="white",
|
||||||
paper_bgcolor="white",
|
paper_bgcolor="white",
|
||||||
xaxis=dict(
|
xaxis=dict(
|
||||||
|
type="date",
|
||||||
showgrid=True,
|
showgrid=True,
|
||||||
gridcolor="lightgray",
|
gridcolor="lightgray",
|
||||||
zeroline=False,
|
zeroline=False,
|
||||||
tickvals=tick_vals,
|
tickformat="%d-%m-%Y %H:%M",
|
||||||
ticktext=tick_text,
|
nticks=6,
|
||||||
tickangle=-25,
|
tickangle=-25,
|
||||||
tickfont=dict(size=22),
|
tickfont=dict(size=22),
|
||||||
title_font=dict(size=28),
|
title_font=dict(size=28),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user