Source code for pymixef.reporting
"""Manifest-driven human-readable reports."""
from __future__ import annotations
import html
from pathlib import Path
from typing import Any
from .results import FitResult
def _markdown(result: FitResult) -> str:
parameter_rows = "\n".join(
f"| `{name}` | {value:.10g} |" for name, value in result.parameters.items()
)
warning_rows = "\n".join(
f"- `{warning.code}` ({warning.severity}): {warning.message}"
for warning in (*result.warnings, *result.convergence.warnings)
)
if not warning_rows:
warning_rows = "No structured warnings were emitted."
covariance_method = result.extra.get("covariance_method", "backend default")
approximation = result.extra.get("approximation", result.engine)
return f"""# PyMixEF model result
## 1. Model purpose and analysis population
This report was generated from the immutable result and run manifest. Scientific
purpose and estimand context must be supplied by the analysis owner.
## 2. Data audit
- Analysis observations: {result.n_observations}
- Input data hash: `{result.manifest.data_hash}`
- Model IR hash: `{result.manifest.model_ir_hash}`
## 3. Mathematical model and transformations
The archived model IR is included in the machine-readable result. Parameter values
below are reported on their natural scale unless the model IR states otherwise.
## 4. Estimator and approximation
- Engine: `{result.engine}`
- Method: `{result.method}`
- Approximation: `{approximation}`
- Objective: `{result.objective:.12g}`
- Log likelihood: `{result.log_likelihood}`
## 5. Convergence and numerical diagnostics
- Status: `{result.convergence.status}`
- Optimizer terminated: `{result.convergence.optimizer_terminated}`
- Scaled gradient infinity norm: `{result.convergence.scaled_gradient_inf_norm}`
- Hessian positive definite: `{result.convergence.hessian.positive_definite}`
- Conditional-mode failures: `{result.convergence.conditional_mode_failures}`
- ODE failures: `{result.convergence.ode_failures}`
## 6. Parameter estimates and uncertainty
| Parameter | Estimate |
|---|---:|
{parameter_rows}
Uncertainty method: `{covariance_method}`. See the machine-readable result for the
full covariance matrix and approximation metadata.
## 7. Random effects and predictions
Random-effect estimates and explicit conditional/population prediction arrays are
stored in the result object when supported by the selected backend.
## 8. Diagnostic data
Available serialized tables: {", ".join(sorted(result.diagnostic_data)) or "none"}.
Plots are intentionally a separate presentation layer.
## 9. Sensitivity analyses
No sensitivity analysis is implied by this primary fit. Use approximation,
quadrature, tolerance, bootstrap, or robust-comparison workflows explicitly.
## 10. Simulation
The result declares reproducibility class
`{result.manifest.reproducibility_class}` and records all supplied seeds.
## 11. Known limitations and warnings
{warning_rows}
## 12. Run manifest
- PyMixEF version: `{result.manifest.package_version}`
- Manifest schema: `{result.manifest.manifest_schema_version}`
- Created UTC: `{result.manifest.created_at_utc}`
- Environment and thread settings are present in the archived manifest.
> PyMixEF supplies evidence and reproducibility tooling; this report is not a universal regulatory validation certificate.
"""
[docs]
def render_report(result: FitResult, path: str | Path) -> Path:
"""Render a result as Markdown, HTML, PDF, or Word.
PDF and Word require the optional ``report`` dependencies. All formats are
generated from the same immutable Markdown content.
"""
destination = Path(path)
destination.parent.mkdir(parents=True, exist_ok=True)
markdown = _markdown(result)
suffix = destination.suffix.lower()
if suffix in {".md", ".markdown"}:
destination.write_text(markdown, encoding="utf-8")
return destination
if suffix == ".html":
try:
import markdown as markdown_package
body = markdown_package.markdown(markdown, extensions=["tables"])
except ImportError:
body = "<pre>" + html.escape(markdown) + "</pre>"
destination.write_text(
"<!doctype html><html lang='en'><meta charset='utf-8'>"
"<title>PyMixEF report</title><style>"
"body{font:16px system-ui;max-width:960px;margin:2rem auto;line-height:1.5}"
"table{border-collapse:collapse}th,td{border:1px solid #bbb;padding:.4rem}"
"code{background:#f5f5f5;padding:.1rem .25rem}</style>"
f"<body>{body}</body></html>",
encoding="utf-8",
)
return destination
if suffix == ".pdf":
try:
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer
except ImportError as error:
raise ImportError("PDF reports require `pip install pymixef[report]`.") from error
styles = getSampleStyleSheet()
story: list[Any] = []
for block in markdown.split("\n\n"):
cleaned = html.escape(block).replace("\n", "<br/>")
style = styles["Heading1"] if block.startswith("# ") else styles["BodyText"]
story.extend((Paragraph(cleaned.lstrip("# "), style), Spacer(1, 0.12 * inch)))
document = SimpleDocTemplate(
str(destination),
pagesize=letter,
rightMargin=0.65 * inch,
leftMargin=0.65 * inch,
topMargin=0.65 * inch,
bottomMargin=0.65 * inch,
)
document.build(story)
return destination
if suffix == ".docx":
try:
from docx import Document
except ImportError as error:
raise ImportError("Word reports require `pip install pymixef[report]`.") from error
document = Document()
for line in markdown.splitlines():
if line.startswith("# "):
document.add_heading(line[2:], level=0)
elif line.startswith("## "):
document.add_heading(line[3:], level=1)
elif line.startswith("- "):
document.add_paragraph(line[2:], style="List Bullet")
elif line and not line.startswith("|"):
document.add_paragraph(line)
document.save(destination)
return destination
raise ValueError("Report path must end in .md, .html, .pdf, or .docx.")