"""
api/reporting.py - Report generation for Episia analyses.
Generates structured epidemiological reports in multiple formats:
- Markdown (.md)
- HTML (.html) self-contained, print-ready
- JSON (.json) machine-readable full export
Public classes
--------------
EpiReport builder: add sections, tables, figures, then export
ReportSection typed section (text, table, figure, metrics)
Public functions
----------------
report_from_result() one-line report from any EpiResult
report_from_model() full model run report with plots
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
[docs]
@dataclass
class ReportSection:
kind: str
content: Any
title: Optional[str] = None
level: int = 2
caption: Optional[str] = None
[docs]
class EpiReport:
"""
Structured epidemiological report builder.
Example::
report = EpiReport(title="Analyse SEIR", author="Dr. Ouedraogo")
report.add_text("Introduction...", title="Introduction")
report.add_metrics({"R0": 3.2, "Peak": 42500})
report.add_table(df, title="Results")
report.save_html("rapport.html")
report.save_markdown("rapport.md")
"""
[docs]
def __init__(
self,
title: str = "Epidemiological report",
author: Optional[str] = None,
institution: Optional[str] = None,
date: Optional[str] = None,
description: Optional[str] = None,
):
self.title = title
self.author = author
self.institution = institution
self.date = date or datetime.now().strftime("%d %B %Y")
self.description = description
self.sections: List[ReportSection] = []
# Adders ─────
[docs]
def add_text(self, text: str, title: Optional[str] = None,
level: int = 2) -> "EpiReport":
self.sections.append(ReportSection(
kind="text", content=text, title=title, level=level))
return self
[docs]
def add_metrics(self, metrics: Dict[str, Any],
title: Optional[str] = "Key indicators") -> "EpiReport":
self.sections.append(ReportSection(
kind="metrics", content=metrics, title=title))
return self
[docs]
def add_table(self, data: Any, title: Optional[str] = None,
caption: Optional[str] = None,
max_rows: int = 100) -> "EpiReport":
try:
import pandas as pd
if not isinstance(data, pd.DataFrame):
data = pd.DataFrame(data)
if len(data) > max_rows:
caption = (caption or "") + f"\n*Truncated to {max_rows}/{len(data)} rows.*"
data = data.head(max_rows)
except ImportError:
pass
self.sections.append(ReportSection(
kind="table", content=data, title=title, caption=caption))
return self
[docs]
def add_result(self, result: Any,
title: Optional[str] = None) -> "EpiReport":
repr_str = repr(result)
self.sections.append(ReportSection(
kind="text",
content="```\n" + repr_str + "\n```",
title=title or result.__class__.__name__,
))
if hasattr(result, "to_dict"):
d = result.to_dict()
flat = {k: _fmt(v) for k, v in d.items()
if not isinstance(v, (dict, list))}
if flat:
self.sections.append(ReportSection(
kind="metrics", content=flat))
return self
[docs]
def add_divider(self) -> "EpiReport":
self.sections.append(ReportSection(kind="divider", content=None))
return self
# Markdown ────
[docs]
def to_markdown(self) -> str:
lines = [f"# {self.title}\n"]
if self.author:
lines.append(f"**Author:** {self.author} ")
if self.institution:
lines.append(f"**Institution:** {self.institution} ")
lines.append(f"**Date:** {self.date}\n")
if self.description:
lines.append(f"*{self.description}*\n")
lines.append("---\n")
for sec in self.sections:
if sec.kind == "divider":
lines.append("\n---\n"); continue
if sec.title:
lines.append(f"\n{'#' * min(sec.level, 4)} {sec.title}\n")
if sec.kind == "text":
lines.append(str(sec.content))
elif sec.kind == "metrics":
lines += ["| Indicator | Value |",
"|:-----------|-------:|"]
for k, v in sec.content.items():
lines.append(f"| {k} | {_fmt(v)} |")
elif sec.kind == "table":
try:
import pandas as pd
if isinstance(sec.content, pd.DataFrame):
lines.append(sec.content.to_markdown(index=True))
else:
lines.append(str(sec.content))
except ImportError:
lines.append(str(sec.content))
elif sec.kind == "figure":
lines.append("*[Figure — see HTML version]*")
if sec.caption:
lines.append(f"\n*{sec.caption}*")
lines.append("")
return "\n".join(lines)
[docs]
def save_markdown(self, path: Union[str, Path]) -> Path:
path = Path(path)
path.write_text(self.to_markdown(), encoding="utf-8")
return path
# HTML ────────
[docs]
def to_html(self) -> str:
parts = []
for sec in self.sections:
if sec.kind == "divider":
parts.append("<hr>"); continue
if sec.title:
tag = f"h{min(sec.level, 4)}"
parts.append(f"<{tag}>{_esc(sec.title)}</{tag}>")
if sec.kind == "text":
content = str(sec.content)
if content.startswith("```"):
inner = content.strip("`").strip()
code = "\n".join(inner.split("\n")[1:])
parts.append(f"<pre><code>{_esc(code)}</code></pre>")
else:
for para in content.split("\n\n"):
if para.strip():
parts.append(f"<p>{_esc(para.strip())}</p>")
elif sec.kind == "metrics":
rows = "".join(
f"<tr><td>{_esc(str(k))}</td>"
f"<td class='val'>{_esc(_fmt(v))}</td></tr>"
for k, v in sec.content.items()
)
parts.append(
f"<table class='metrics'><thead><tr>"
f"<th>Indicator</th><th>Value</th></tr></thead>"
f"<tbody>{rows}</tbody></table>"
)
elif sec.kind == "table":
try:
import pandas as pd
if isinstance(sec.content, pd.DataFrame):
parts.append(sec.content.to_html(
classes="epi-table", border=0,
float_format=lambda x: f"{x:.4f}"))
else:
parts.append(f"<pre>{_esc(str(sec.content))}</pre>")
except ImportError:
parts.append(f"<pre>{_esc(str(sec.content))}</pre>")
elif sec.kind == "figure":
parts.append(f'<div class="figure">{sec.content}</div>')
if sec.caption:
parts.append(f'<p class="caption">{_esc(sec.caption)}</p>')
meta = ""
if self.author:
meta += f"<span><strong>Author:</strong> {_esc(self.author)}</span>"
if self.institution:
meta += f"<span><strong>Institution:</strong> {_esc(self.institution)}</span>"
meta += f"<span><strong>Date:</strong> {_esc(self.date)}</span>"
desc = f'<p class="desc">{_esc(self.description)}</p>' if self.description else ""
return _HTML_TEMPLATE.format(
title=_esc(self.title), meta=meta,
description=desc, body="\n".join(parts),
)
[docs]
def save_html(self, path: Union[str, Path]) -> Path:
path = Path(path)
path.write_text(self.to_html(), encoding="utf-8")
return path
# JSON ────────
[docs]
def to_json(self, indent: int = 2) -> str:
export: Dict[str, Any] = {
"title": self.title, "author": self.author,
"institution": self.institution, "date": self.date,
"sections": [],
}
for sec in self.sections:
entry: Dict[str, Any] = {"kind": sec.kind, "title": sec.title}
if sec.kind in ("text", "divider"):
entry["content"] = str(sec.content) if sec.content else None
elif sec.kind == "metrics":
entry["content"] = {str(k): _fmt(v) for k, v in sec.content.items()}
elif sec.kind == "table":
try:
import pandas as pd
entry["content"] = (
sec.content.to_dict(orient="records")
if isinstance(sec.content, pd.DataFrame)
else str(sec.content)
)
except ImportError:
entry["content"] = str(sec.content)
elif sec.kind == "figure":
entry["content"] = "[figure omitted]"
if sec.caption:
entry["caption"] = sec.caption
export["sections"].append(entry)
return json.dumps(export, ensure_ascii=False, indent=indent,
default=_json_default)
[docs]
def save_json(self, path: Union[str, Path]) -> Path:
path = Path(path)
path.write_text(self.to_json(), encoding="utf-8")
return path
def __repr__(self) -> str:
return f"EpiReport('{self.title}', {len(self.sections)} sections)"
# Factory functions
[docs]
def report_from_result(
result: Any,
title: Optional[str] = None,
author: Optional[str] = None,
backend: str = "plotly",
theme: str = "scientific",
) -> EpiReport:
"""One-line report from any EpiResult."""
report = EpiReport(
title=title or f"Report — {result.__class__.__name__}",
author=author,
)
report.add_result(result, title="Results")
try:
fig = result.plot(backend=backend)
report.add_figure(fig, title="Visualization")
except Exception:
pass
return report
[docs]
def report_from_model(
model_result: Any,
title: Optional[str] = None,
author: Optional[str] = None,
institution: Optional[str] = None,
sensitivity_result: Optional[Any] = None,
backend: str = "plotly",
theme: str = "scientific",
) -> EpiReport:
"""Full model simulation report (SIR / SEIR / SEIRD)."""
mtype = model_result.model_type
report = EpiReport(
title=title or f"Model report — {mtype}",
author=author, institution=institution,
)
report.add_text(
f"Compartmental model simulation **{mtype}** "
f"with the parameters below.",
title="Introduction",
)
params = {k: _fmt(v) for k, v in model_result.parameters.items()
if not isinstance(v, (list, dict))}
report.add_metrics(params, title="Parameters")
metrics: Dict[str, Any] = {}
if model_result.r0 is not None:
metrics["R₀"] = f"{model_result.r0:.3f}"
hit = max(0.0, 1 - 1 / model_result.r0) if model_result.r0 > 1 else 0.0
metrics["Herd immunity threshold"] = f"{hit:.1%}"
if model_result.peak_infected is not None:
metrics["Peak infectious"] = f"{model_result.peak_infected:,.0f}"
metrics["Peak day"] = f"{model_result.peak_time:.0f}"
if model_result.final_size is not None:
metrics["Final size"] = f"{model_result.final_size:.1%}"
n = model_result.parameters.get("N", 1)
metrics["Estimated total cases"] = f"{model_result.final_size * n:,.0f}"
report.add_metrics(metrics, title="Epidemiological indicators")
try:
fig = model_result.plot(backend=backend)
report.add_figure(fig, title="Model trajectories",
caption=f"Simulation {mtype} — S/E/I/R(/D) compartments.")
except Exception:
pass
if sensitivity_result is not None:
report.add_divider()
report.add_text(
f"Monte Carlo sensitivity analysis "
f"({sensitivity_result.n_samples} samples).",
title="Sensitivity analysis",
)
s = sensitivity_result.summary()
sa_m: Dict[str, str] = {}
for m, lbl in [("r0","R₀"), ("peak_infected","Peak infectious"),
("final_size","Final size")]:
if f"{m}_median" in s:
sa_m[f"{lbl} median"] = _fmt(s[f"{m}_median"])
sa_m[f"{lbl} 90% CI"] = (
f"[{_fmt(s[f'{m}_p5'])}, {_fmt(s[f'{m}_p95'])}]")
report.add_metrics(sa_m)
try:
fig_sa = sensitivity_result.plot(compartment="I", backend=backend)
report.add_figure(fig_sa, title="Uncertainty envelope",
caption="Percentiles 5–25–50–75–95.")
except Exception:
pass
try:
df = model_result.to_dataframe()
idx = [0, len(df)//4, len(df)//2, 3*len(df)//4, len(df)-1]
report.add_table(df.iloc[idx], title="Trajectory excerpt",
caption="Values at t=0, 25%, 50%, 75%, end.")
except Exception:
pass
return report
# Helpers
def _fmt(v: Any) -> str:
if v is None: return ""
if isinstance(v, float):
return f"{v:,.1f}" if abs(v) >= 1000 else f"{v:.4f}"
if isinstance(v, int):
return f"{v:,}"
return str(v)
def _esc(s: str) -> str:
return (str(s).replace("&","&").replace("<","<")
.replace(">",">").replace('"',"""))
def _json_default(obj: Any) -> Any:
import numpy as np
if isinstance(obj, np.integer): return int(obj)
if isinstance(obj, np.floating): return float(obj)
if isinstance(obj, np.ndarray): return obj.tolist()
return str(obj)
def _figure_to_html(figure: Any, width: str = "100%") -> str:
try:
import plotly.io as pio
return pio.to_html(figure, full_html=False,
include_plotlyjs="cdn", default_width=width)
except Exception:
pass
try:
import io, base64
buf = io.BytesIO()
figure.savefig(buf, format="png", dpi=120, bbox_inches="tight")
buf.seek(0)
b64 = base64.b64encode(buf.read()).decode()
return (f'<img src="data:image/png;base64,{b64}" '
f'style="width:{width};max-width:100%;" alt="figure">')
except Exception:
return "<p><em>[Figure not available]</em></p>"
_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Episia - {title}</title>
<style>
:root {{
--bg: #fbfbfd;
--glass-bg: rgba(255, 255, 255, 0.75);
--glass-border: rgba(255, 255, 255, 0.4);
--text-main: #1d1d1f;
--text-dim: #6e6e73;
--accent: #0071e3;
--accent-soft: rgba(0, 113, 227, 0.1);
--card-shadow: 0 20px 40px rgba(0,0,0,0.04);
--table-hover: rgba(0,0,0,0.02);
}}
[data-theme="dark"] {{
--bg: #000000;
--glass-bg: rgba(28, 28, 30, 0.7);
--glass-border: rgba(255, 255, 255, 0.1);
--text-main: #f5f5f7;
--text-dim: #86868b;
--accent: #2997ff;
--accent-soft: rgba(41, 151, 255, 0.15);
--card-shadow: 0 20px 40px rgba(0,0,0,0.4);
--table-hover: rgba(255,255,255,0.05);
}}
*, *::before, *::after {{ box-sizing: border-box; transition: background-color 0.4s cubic-bezier(0.4, 0, 0.2, 1), color 0.3s ease; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", sans-serif;
background-color: var(--bg);
color: var(--text-main);
margin: 0; padding: 0;
display: flex; flex-direction: column; align-items: center;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}}
nav {{
width: 100%; max-width: 900px;
display: flex; justify-content: space-between; align-items: center;
padding: 2rem; position: sticky; top: 0; z-index: 100;
backdrop-filter: blur(10px);
}}
.theme-toggle {{
background: var(--glass-bg);
border: 1px solid var(--glass-border);
padding: 8px 16px; border-radius: 20px;
cursor: pointer; color: var(--text-main);
font-size: 0.85rem; font-weight: 500;
display: flex; align-items: center; gap: 8px;
box-shadow: var(--card-shadow);
}}
.theme-toggle:hover {{ transform: scale(1.05); }}
.container {{ width: 100%; max-width: 850px; padding: 0 1.5rem 5rem; }}
header h1 {{
font-size: 3.2rem; font-weight: 700;
letter-spacing: -0.04em; margin-bottom: 0.5rem; text-align: center;
}}
.meta {{
display: flex; justify-content: center; gap: 2rem;
color: var(--text-dim); font-size: 0.9rem; margin-bottom: 3rem;
}}
.glass-panel {{
position: relative;
background: var(--glass-bg);
backdrop-filter: blur(25px) saturate(180%);
-webkit-backdrop-filter: blur(25px) saturate(180%);
border: 1px solid var(--glass-border);
border-radius: 28px; padding: 4rem 3rem 3rem 3rem;
box-shadow: var(--card-shadow);
}}
h2 {{ font-size: 1.6rem; margin-top: 2rem; font-weight: 600; letter-spacing: -0.02em; }}
h3 {{ font-size: 1.2rem; margin-top: 1.5rem; font-weight: 600; }}
p {{ line-height: 1.7; color: var(--text-main); }}
table {{ width: 100%; border-collapse: collapse; margin: 2rem 0; }}
th {{
text-align: left; color: var(--text-dim);
font-size: 0.75rem; text-transform: uppercase;
letter-spacing: 0.1em; padding: 1rem;
border-bottom: 1px solid var(--glass-border);
}}
td {{
padding: 1.2rem 1rem;
border-bottom: 1px solid var(--glass-border);
font-size: 0.95rem;
}}
tr:hover td {{ background: var(--table-hover); }}
.val {{
font-family: "SF Mono", "Fira Code", monospace;
font-weight: 600; color: var(--accent); text-align: right;
}}
table.metrics th {{ background: none; }}
table.epi-table th {{ background: none; }}
pre {{
background: rgba(0,0,0,0.05); padding: 1.5rem;
border-radius: 18px;
font-family: "SF Mono", monospace; font-size: 0.85rem;
overflow-x: auto; border: 1px solid var(--glass-border);
}}
[data-theme="dark"] pre {{ background: rgba(255,255,255,0.05); }}
.figure {{ margin: 2rem 0; }}
.caption {{ font-size: 0.85rem; color: var(--text-dim); font-style: italic; margin-top: 0.5rem; }}
hr {{ border: none; border-top: 1px solid var(--glass-border); margin: 2rem 0; }}
.copy-main-btn {{
position: absolute; top: 20px; right: 24px;
background: var(--glass-bg); border: 1px solid var(--glass-border);
color: var(--text-main); padding: 8px 16px; border-radius: 20px;
font-size: 0.8rem; font-weight: 600; cursor: pointer;
backdrop-filter: blur(10px);
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
display: flex; align-items: center; gap: 6px;
}}
.copy-main-btn:hover {{ color: var(--accent); border-color: var(--accent); }}
footer {{ margin-top: 4rem; text-align: center; color: var(--text-dim); font-size: 0.85rem; }}
@media (max-width: 600px) {{
header h1 {{ font-size: 2.2rem; }}
.glass-panel {{ padding: 3.5rem 1.5rem 1.5rem 1.5rem; }}
}}
</style>
</head>
<body>
<nav>
<div style="font-weight:700;font-size:1.2rem;letter-spacing:-1px;">
Epit<span style="color:var(--accent)">oo</span>ls<span style="color:var(--accent)">.</span>
</div>
<button class="theme-toggle" id="theme-btn">
<span id="theme-text">dark</span>
</button>
</nav>
<div class="container">
<header>
<h1>{title}</h1>
<div class="meta">{meta}</div>
</header>
<main class="glass-panel">
<button class="copy-main-btn" id="copy-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
</button>
<div id="content-to-copy">
{description}
<div style="margin-top:2rem">{body}</div>
</div>
</main>
<footer>
<p>© <span id="yr">2026</span> Episia Xcept-Health</p>
</footer>
</div>
<script>
const btn = document.getElementById('theme-btn');
const themeText = document.getElementById('theme-text');
const html = document.documentElement;
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {{
html.setAttribute('data-theme','dark'); themeText.innerText='light';
}}
btn.addEventListener('click', () => {{
if (html.getAttribute('data-theme')==='light') {{
html.setAttribute('data-theme','dark'); themeText.innerText='light';
}} else {{
html.setAttribute('data-theme','light'); themeText.innerText='dark';
}}
}});
const y = new Date().getFullYear();
if (y > 2026) document.getElementById('yr').innerText = '2026 - ' + y;
document.getElementById('copy-btn').addEventListener('click', async () => {{
try {{
await navigator.clipboard.writeText(document.getElementById('content-to-copy').innerText.trim());
document.getElementById('copy-btn').style.color = 'var(--accent)';
setTimeout(() => document.getElementById('copy-btn').style.color = '', 2000);
}} catch(e) {{ console.error(e); }}
}});
</script>
</body>
</html>"""
__all__ = [
"EpiReport", "ReportSection",
"report_from_result", "report_from_model",
]