252 lines
9.1 KiB
Python
252 lines
9.1 KiB
Python
"""Excel error report writer (.xlsx).
|
|
|
|
Optional dependency: ``openpyxl``. When the package is missing the
|
|
caller is expected to fall back gracefully (e.g. log a warning and
|
|
continue without the xlsx artifact).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .api import DeflectionResult
|
|
from .artifacts import _require_artifacts, _annotate_beam_track_rows
|
|
from .beams import candidate_rows
|
|
from .io import ensure_output_dir
|
|
|
|
|
|
SUMMARY_SHEET = "Summary"
|
|
CURVES_SHEET = "Deflection_Curves"
|
|
TRACKS_SHEET = "Beam_Tracks"
|
|
REFERENCE_SHEET = "Reference_Lines"
|
|
REPRESENTATIVES_SHEET = "Representative_Points"
|
|
|
|
|
|
def write_excel_report(
|
|
result: DeflectionResult,
|
|
path: str | Path,
|
|
*,
|
|
inputs: dict[str, Any] | None = None,
|
|
registration_info: dict[str, Any] | None = None,
|
|
) -> Path:
|
|
"""Write a 5-sheet Excel error report next to the JSON artifacts.
|
|
|
|
``inputs`` is a free-form dict of input paths (scan / cad / roi) and
|
|
profile; it is rendered verbatim into the Summary sheet.
|
|
``registration_info`` may include ``rms_mm``, ``iterations``,
|
|
``inlier_ratio``, ``converged``, ``initial_rms_mm``.
|
|
"""
|
|
try:
|
|
from openpyxl import Workbook
|
|
except ImportError as exc: # pragma: no cover - exercised at runtime only
|
|
raise ImportError(
|
|
"Writing Excel reports requires the 'openpyxl' package. "
|
|
"Install with: pip install openpyxl"
|
|
) from exc
|
|
|
|
output_path = Path(path)
|
|
ensure_output_dir(output_path.parent)
|
|
artifacts = _require_artifacts(result)
|
|
|
|
wb = Workbook()
|
|
wb.remove(wb.active)
|
|
|
|
_write_summary(wb, result, artifacts, inputs or {}, registration_info or {})
|
|
_write_curves(wb, artifacts)
|
|
_write_tracks(wb, artifacts)
|
|
_write_reference_lines(wb, result)
|
|
_write_representatives(wb, result)
|
|
|
|
wb.save(output_path)
|
|
return output_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sheet builders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _write_summary(
|
|
wb,
|
|
result: DeflectionResult,
|
|
artifacts,
|
|
inputs: dict[str, Any],
|
|
reg: dict[str, Any],
|
|
) -> None:
|
|
ws = wb.create_sheet(SUMMARY_SHEET)
|
|
ws.append(["key", "value"])
|
|
rows: list[tuple[str, Any]] = []
|
|
|
|
rows.append(("schema_version", result.schema_version))
|
|
rows.append(("algorithm_version", result.algorithm_version))
|
|
rows.append(("profile", inputs.get("profile", "")))
|
|
rows.append(("scan_path", inputs.get("scan_path", "")))
|
|
rows.append(("cad_path", inputs.get("cad_path", "")))
|
|
rows.append(("roi_path", inputs.get("roi_path", "")))
|
|
rows.append(("config_path", inputs.get("config_path", "")))
|
|
|
|
rows.append(("registration_enabled", reg.get("enabled", False)))
|
|
rows.append(("registration_rms_mm", reg.get("rms_mm", "")))
|
|
rows.append(("registration_initial_rms_mm", reg.get("initial_rms_mm", "")))
|
|
rows.append(("registration_iterations", reg.get("iterations", "")))
|
|
rows.append(("registration_inlier_ratio", reg.get("inlier_ratio", "")))
|
|
rows.append(("registration_converged", reg.get("converged", "")))
|
|
|
|
preprocess = result.report.get("preprocess", {}) or {}
|
|
for key in (
|
|
"raw_count",
|
|
"after_passthrough",
|
|
"passthrough_applied",
|
|
"after_statistical_filter",
|
|
"after_radius_filter",
|
|
"after_bilateral",
|
|
"bilateral_applied",
|
|
"downsampled_count",
|
|
"spacing_mm",
|
|
"statistical_keep_ratio",
|
|
"radius_keep_ratio",
|
|
):
|
|
if key in preprocess:
|
|
rows.append((f"preprocess.{key}", preprocess[key]))
|
|
|
|
deflection = result.report.get("deflection", {}) or {}
|
|
for side in ("left", "right"):
|
|
side_data = deflection.get(side, {}) or {}
|
|
selected = side_data.get("selected_measurement", {}) or {}
|
|
rows.append((f"{side}.available", side_data.get("available", False)))
|
|
rows.append((f"{side}.reference_available", side_data.get("reference_available", False)))
|
|
rows.append((f"{side}.reference_confidence", side_data.get("reference_confidence", "")))
|
|
rows.append((f"{side}.max_downward_deflection_mm", side_data.get("max_downward_deflection_mm", "")))
|
|
rows.append((f"{side}.max_downward_x_mm", side_data.get("max_downward_x_mm", "")))
|
|
rows.append((f"{side}.max_upward_deflection_mm", side_data.get("max_upward_deflection_mm", "")))
|
|
rows.append((f"{side}.max_upward_x_mm", side_data.get("max_upward_x_mm", "")))
|
|
rows.append((f"{side}.selected_x_mm", selected.get("x_mm", "")))
|
|
rows.append((f"{side}.selected_signed_deflection_mm", selected.get("signed_deflection_mm", "")))
|
|
rows.append((f"{side}.selected_absolute_deflection_mm", selected.get("absolute_deflection_mm", "")))
|
|
rows.append((f"{side}.beam_fit_residual_rms_z", side_data.get("beam_fit_residual_rms_z", "")))
|
|
|
|
rows.append(("quality_flags", "; ".join(result.quality_flags)))
|
|
|
|
roi_payload = result.report.get("roi", {}) or {}
|
|
for key in ("source", "x_min", "x_max", "y_min", "y_max", "z_min", "z_max", "z_ceiling", "point_count"):
|
|
if key in roi_payload:
|
|
rows.append((f"roi.{key}", roi_payload[key]))
|
|
|
|
for key, value in rows:
|
|
ws.append([key, _coerce_cell(value)])
|
|
|
|
ws.column_dimensions["A"].width = 42
|
|
ws.column_dimensions["B"].width = 32
|
|
|
|
|
|
def _write_curves(wb, artifacts) -> None:
|
|
ws = wb.create_sheet(CURVES_SHEET)
|
|
rows = artifacts.deflection_rows
|
|
if not rows:
|
|
ws.append(["x"])
|
|
return
|
|
fieldnames = list(rows[0].keys())
|
|
ws.append(fieldnames)
|
|
for row in rows:
|
|
ws.append([_coerce_cell(row.get(name)) for name in fieldnames])
|
|
|
|
|
|
def _write_tracks(wb, artifacts) -> None:
|
|
ws = wb.create_sheet(TRACKS_SHEET)
|
|
rows = _annotate_beam_track_rows(
|
|
candidate_rows(artifacts.slices),
|
|
artifacts.left_tracks,
|
|
artifacts.right_tracks,
|
|
)
|
|
if not rows:
|
|
ws.append(["slice_index", "candidate_id", "side"])
|
|
return
|
|
fieldnames = list(rows[0].keys())
|
|
ws.append(fieldnames)
|
|
for row in rows:
|
|
ws.append([_coerce_cell(row.get(name)) for name in fieldnames])
|
|
|
|
|
|
def _write_reference_lines(wb, result: DeflectionResult) -> None:
|
|
ws = wb.create_sheet(REFERENCE_SHEET)
|
|
header = [
|
|
"side",
|
|
"available",
|
|
"confidence",
|
|
"residual",
|
|
"front_x",
|
|
"front_y",
|
|
"front_z",
|
|
"back_x",
|
|
"back_y",
|
|
"back_z",
|
|
"dir_x",
|
|
"dir_y",
|
|
"dir_z",
|
|
]
|
|
ws.append(header)
|
|
for side in ("left", "right"):
|
|
ref = (result.report.get("reference_lines") or {}).get(side) or {}
|
|
front = ref.get("point_front") or [None, None, None]
|
|
back = ref.get("point_back") or [None, None, None]
|
|
direction = ref.get("direction") or [None, None, None]
|
|
ws.append(
|
|
[
|
|
side,
|
|
_coerce_cell(ref.get("available")),
|
|
_coerce_cell(ref.get("confidence")),
|
|
_coerce_cell(ref.get("residual")),
|
|
_coerce_cell(front[0] if len(front) > 0 else None),
|
|
_coerce_cell(front[1] if len(front) > 1 else None),
|
|
_coerce_cell(front[2] if len(front) > 2 else None),
|
|
_coerce_cell(back[0] if len(back) > 0 else None),
|
|
_coerce_cell(back[1] if len(back) > 1 else None),
|
|
_coerce_cell(back[2] if len(back) > 2 else None),
|
|
_coerce_cell(direction[0] if len(direction) > 0 else None),
|
|
_coerce_cell(direction[1] if len(direction) > 1 else None),
|
|
_coerce_cell(direction[2] if len(direction) > 2 else None),
|
|
]
|
|
)
|
|
|
|
|
|
def _write_representatives(wb, result: DeflectionResult) -> None:
|
|
ws = wb.create_sheet(REPRESENTATIVES_SHEET)
|
|
header = ["side", "slice_index", "x", "y", "z", "width", "point_count", "residual", "confidence"]
|
|
ws.append(header)
|
|
rep_payload = result.report.get("representative_points") or {}
|
|
for side in ("left", "right"):
|
|
for item in rep_payload.get(side, []) or []:
|
|
ws.append(
|
|
[
|
|
side,
|
|
_coerce_cell(item.get("slice_index")),
|
|
_coerce_cell(item.get("x")),
|
|
_coerce_cell(item.get("y")),
|
|
_coerce_cell(item.get("z")),
|
|
_coerce_cell(item.get("width")),
|
|
_coerce_cell(item.get("point_count")),
|
|
_coerce_cell(item.get("residual")),
|
|
_coerce_cell(item.get("confidence")),
|
|
]
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _coerce_cell(value: Any) -> Any:
|
|
"""Map non-primitive types to plain Python so openpyxl can write them."""
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, (str, int, float, bool)):
|
|
return value
|
|
if isinstance(value, (list, tuple)):
|
|
return ",".join(str(_coerce_cell(v)) for v in value)
|
|
return str(value)
|
|
|
|
|
|
__all__ = ["write_excel_report"]
|