"""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"]