.. _report-guide:
================
Building Reports
================
.. _report-concepts:
Scientific Concepts and Principles
==================================
Reports are the boundary where computation meets the reader. The
``coco_pipe.report`` module is built around a few principles that keep
that boundary trustworthy, reproducible, and offline-friendly.
---
1. Tree of Sections, Tree of Elements
-------------------------------------
A :class:`~coco_pipe.report.core.Report` is a container of
:class:`~coco_pipe.report.core.Section` objects. Each section is itself
a container of :class:`~coco_pipe.report.elements.Element` instances —
images, Plotly figures, tables, code blocks, callouts, accordions, and
so on (see :ref:`report-elements`).
.. code-block:: text
Report
├── Section ("Overview")
│ ├── TableElement
│ └── CalloutElement
├── Section ("Performance")
│ ├── PlotlyElement
│ ├── TableElement
│ └── ImageElement
└── Section ("Provenance")
└── CodeBlockElement
This tree is rendered in two passes:
1. **Collect** — every element pushes payload data into a global
registry keyed by UUID.
2. **Render** — Jinja templates emit HTML; elements that own payload
data emit a placeholder ``
``;
the browser hydrates them on demand.
---
2. Self-Contained, Lazy-Hydrating HTML
--------------------------------------
A rendered report is a single ``.html`` file. Heavy data (Plotly
figures, interactive tables) is not duplicated per element — it goes
through one registry:
.. code-block:: text
Report.render():
registry = {} # one dict, all elements
self.collect_payload(registry) # walk tree, populate
payload = json.dumps(registry).encode("utf-8")
payload = gzip.compress(payload)
payload = base64.b64encode(payload).decode("utf-8")
...
This contract lets us:
- compress all data once (typically 60-90 % size reduction),
- render only what scrolls into view (no upfront Plotly cost),
- keep the file self-contained: no sibling assets to ship.
The :meth:`Report.render `
docstring spells out the contract; the matching browser-side decoder
lives in ``templates/static/report_scripts.html``.
---
3. Three Asset Modes
--------------------
Reports load three JavaScript bundles (Plotly, Tailwind Play CDN, pako).
The :class:`Report` constructor takes ``asset_urls=`` controlling how
they're sourced:
.. list-table::
:header-rows: 1
* - ``asset_urls`` argument
- Behavior
* - ``None`` (default)
- ````. See :ref:`report-assets`.
* - ``_resolve_config(config)``
- Coerces ``config`` into a :class:`ReportConfig`, honoring an explicit ``title`` argument.
.. rubric:: Section adders
.. list-table::
:header-rows: 1
* - Method
- Use
* - :meth:`Report.add_section(section)`
- Append a pre-built :class:`Section`. IDs are auto-uniqued on collision.
* - :meth:`Report.add_figure(fig, caption=)`
- Shortcut: wraps a matplotlib / Plotly figure in its own section.
* - :meth:`Report.add_container(container, ...)`
- Inspect a :class:`~coco_pipe.io.DataContainer`: dimensions, coordinates, missingness, flatline / outlier checks, sample histograms.
* - :meth:`Report.add_raw_preview(data, name=)`
- Interactive Plotly preview for raw arrays or :class:`DataContainer` X.
* - :meth:`Report.add_summary_card(metrics)`
- Top-of-report key/value stat strip (e.g., ``{"Accuracy": 0.83, "Folds": 5}``).
* - :meth:`Report.add_markdown(text)`
- Append a markdown block (inherited from :class:`ContainerElement`).
* - :meth:`Report.add_element(element)`
- Append any :class:`Element` outside a section.
Domain-specific adders (``add_decoding_*``, ``add_reduction_*``,
``add_reduction``, ``add_comparison``) are bound at import time from
:mod:`~coco_pipe.report.decoding` and :mod:`~coco_pipe.report.dim_reduction`
— see :ref:`report-section-decoding` and
:ref:`report-section-dim-reduction`.
.. rubric:: Rendering
.. list-table::
* - ``render() -> str``
- Collect payload + render full HTML.
* - ``save(filename)``
- Render and write to disk.
* - ``show(port=None)``
- Open the rendered report in a new browser tab via the stdlib ``http.server``.
* - ``_repr_html_()``
- Allows ``report`` to display inline in Jupyter notebooks.
The rendered HTML is fully self-contained: heavy data (Plotly figures,
interactive tables) is collected into one gzip+base64 payload embedded
in the page (see :ref:`report-concepts`).
---
4. Common Patterns
------------------
4.1 One-shot save
~~~~~~~~~~~~~~~~~
.. code-block:: python
from coco_pipe.report import Report
from coco_pipe.report.elements import PlotlyElement
Report("Quick").add_element(PlotlyElement(fig)).save("quick.html")
4.2 Build a multi-section report by hand
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
from coco_pipe.report import Report, Section
from coco_pipe.report.elements import (
CalloutElement, CodeBlockElement, PlotlyElement, TableElement,
)
report = Report(title="Cross-validation comparison")
overview = Section(title="Overview", description="3 models, 5-fold CV")
overview.add_element(CalloutElement(
"All models trained on the same splits; see Provenance for details.",
kind="info",
))
overview.add_element(TableElement(scores_summary_df))
report.add_section(overview)
plots = Section(title="ROC + Calibration")
plots.add_columns([
PlotlyElement(roc_fig, height="380px"),
PlotlyElement(cal_fig, height="380px"),
])
report.add_section(plots)
src = Section(title="Source", code=open("script.py").read())
src.add_element(CodeBlockElement(open("script.py").read(), language="python"))
report.add_section(src)
report.save("comparison.html")
4.3 Display inline in a notebook
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code-block:: python
report # _repr_html_ auto-displays the rendered HTML in Jupyter
4.4 Serve over HTTP
~~~~~~~~~~~~~~~~~~~
.. code-block:: python
report.show() # picks a free port, opens browser
report.show(port=8123)
---
5. State and Identity
---------------------
- Each section is given an HTML id derived from its title (slugified).
Collisions are auto-suffixed (``"repeated"`` → ``"repeated-2"``).
- The collected payload registry uses element-level UUIDs; nothing in
the HTML hard-codes a section count or position.
- The TOC sidebar is built from the section tree; it updates the
active link via an ``IntersectionObserver`` and sets
``aria-current="location"`` on the active link for screen readers.
---
6. Failure Modes
----------------
.. list-table::
:header-rows: 1
* - Action
- Behavior
* - Missing reducer ``get_summary``
- :meth:`Report.add_reduction` raises ``TypeError``.
* - Empty comparison frame
- :meth:`Report.add_comparison` raises ``ValueError``.
* - Broken ``DataContainer``
- :meth:`Report.add_container` emits a ``UserWarning`` and skips the section.
* - Section produces an error
- The factories (``make_decoding_report`` / ``make_reduction_report``) log the failure and skip that section; the rest of the report still renders.
The factory functions are deliberately permissive: a single bad
section never blocks the rest of the report.
.. _report-api:
Factories and Assembly
======================
:mod:`coco_pipe.report.api` exposes seven top-level helpers — six
``from_*`` factories that produce a populated :class:`Report` from a
domain object, plus :func:`merge_reports` for combining existing
reports.
These are convenience entry points. Internally they call the same
``add_*`` methods documented in :ref:`report-core`,
:ref:`report-section-decoding`, and
:ref:`report-section-dim-reduction`.
---
1. Factory at a Glance
----------------------
.. list-table::
:header-rows: 1
* - Factory
- Input Output sections
* - :func:`~coco_pipe.report.from_container`
- :class:`~coco_pipe.io.DataContainer` Data overview + optional raw preview
* - :func:`~coco_pipe.report.from_bids`
- BIDS root directory Data overview from loaded container
* - :func:`~coco_pipe.report.from_tabular`
- Path to CSV / parquet / etc. Data overview from loaded container
* - :func:`~coco_pipe.report.from_embeddings`
- ``X_emb`` array Standalone embedding section
* - :func:`~coco_pipe.report.from_reductions`
- list of scored :class:`DimReduction` One section per reducer + optional comparison
* - :func:`~coco_pipe.report.from_experiment_result`
- :class:`ExperimentResult` Full decoding report
* - :func:`~coco_pipe.report.merge_reports`
- ``*Report`` arguments Sections of every input, prefixed
Every factory accepts the standard report kwargs: ``title``,
``config``, ``theme``, ``asset_urls``, ``output_path``.
---
2. Container Factories
----------------------
2.1 ``from_container``
~~~~~~~~~~~~~~~~~~~~~~
The simplest path: render a :class:`DataContainer` as a report.
.. code-block:: python
from coco_pipe.io import load_data
from coco_pipe.report import from_container
container = load_data("scores.csv", mode="tabular", target_col="label")
report = from_container(
container,
title="Input Inspection",
raw_preview=True, # adds an interactive scroller
output_path="input.html",
)
The single "Data Overview" section includes dimensions, coordinates,
data-quality findings (missingness, flatline, outliers, constant
columns — see :ref:`report-data-quality`), and a sample histogram.
2.2 ``from_bids``
~~~~~~~~~~~~~~~~~
Wraps :func:`coco_pipe.io.load_data` in BIDS mode + ``from_container``.
.. code-block:: python
from coco_pipe.report import from_bids
report = from_bids(
root="/data/bids_root",
task="resting",
output_path="bids_overview.html",
)
2.3 ``from_tabular``
~~~~~~~~~~~~~~~~~~~~
Wraps :func:`coco_pipe.io.load_data` in tabular mode + ``from_container``.
.. code-block:: python
from coco_pipe.report import from_tabular
report = from_tabular(
path="data/scores.csv",
sep=",",
target_col="label",
title="Score Table Inspection",
)
2.4 ``from_embeddings``
~~~~~~~~~~~~~~~~~~~~~~~
Standalone embedding plot — useful when you have an embedding array
but no fitted :class:`DimReduction` to ship alongside.
.. code-block:: python
from coco_pipe.report import from_embeddings
report = from_embeddings(
X_emb=embedding,
labels=class_ids,
metadata={"subject": subject_ids},
title="UMAP embedding",
)
---
3. Result Factories
-------------------
3.1 ``from_reductions``
~~~~~~~~~~~~~~~~~~~~~~~
Builds a multi-reducer report. Pass ``container=`` to inject a "Data
Overview" section, ``embeddings=`` to enable embedding/trajectory
plots, ``raw_preview=True`` to add a raw-data scroller after the data
overview.
.. code-block:: python
from coco_pipe.report import from_reductions
report = from_reductions(
reductions=[pca, umap, phate],
container=container,
embeddings=[pca_emb, umap_emb, phate_emb],
labels=container.y,
title="PCA vs UMAP vs PHATE",
output_path="reduction.html",
)
Equivalent to :func:`make_reduction_report` plus an extra
``add_container`` call.
3.2 ``from_experiment_result``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Builds a full decoding report from an
:class:`~coco_pipe.decoding.result.ExperimentResult`.
.. code-block:: python
from coco_pipe.report import from_experiment_result
report = from_experiment_result(
result,
feature_metadata=meta_df, # for sensor topomap sections
info=raw.info, # for topomap sensor coordinates
title="Decoding Report",
output_path="decoding.html",
)
Identical surface to :func:`make_decoding_report`; choose whichever
import path reads better in your script.
---
4. ``merge_reports``
--------------------
Combine multiple reports for cross-run / cross-cohort comparison.
Every source section is deep-copied, its title prefixed with the
source report's title, and appended to the merged output in the order
supplied.
.. code-block:: python
from coco_pipe.report import from_experiment_result, merge_reports
r1 = from_experiment_result(result_cohort_a, title="Cohort A")
r2 = from_experiment_result(result_cohort_b, title="Cohort B")
merged = merge_reports(r1, r2, title="Cross-Cohort Comparison")
merged.save("comparison.html")
Raises ``ValueError`` if fewer than two reports are supplied.
The merged report inherits the provenance, theme, and asset mode of
the first input. Override by setting them on the merged report before
saving:
.. code-block:: python
merged = merge_reports(r1, r2)
merged.theme = "poster"
merged.asset_mode = "inline" # rebuild urls accordingly with _resolve_assets
---
5. Argument Conventions
-----------------------
.. list-table::
:header-rows: 1
* - Argument
- Purpose
* - ``title``
- Report title; shown in the header and the browser tab.
* - ``config``
- Dict of free-form parameters. Stored on the :class:`ReportConfig` for the Run Info drawer.
* - ``theme``
- ``"paper"`` (default) / ``"notebook"`` / ``"poster"``. Mirrors :mod:`coco_pipe.viz.theme` modes.
* - ``asset_urls``
- ``None`` / ``dict`` / ``"inline"``. See :ref:`report-assets`.
* - ``output_path``
- Convenience: when set, the factory also calls :meth:`Report.save(output_path)` and returns the report.
* - ``sections``
- ``"default"`` or list of section names. Names are factory-specific (see the section catalogs).
When in doubt, prefer the factory functions over the lower-level
``make_*_report`` functions — they accept everything ``make_*_report``
does, plus optional ``container=`` / ``raw_preview=`` for additional
inspection sections.
.. _report-configuration:
Configuration and Provenance
============================
:mod:`coco_pipe.report.config` defines two pydantic models that govern
how a report identifies itself and the environment that produced it:
- :class:`~coco_pipe.report.config.ReportConfig` — title, author,
description, run parameters.
- :class:`~coco_pipe.report.config.ProvenanceConfig` — git hash,
Python / OS, package versions, command line, timestamp.
Both are strict (pydantic ``extra="allow"`` for forward-compatibility),
serializable to JSON, and rendered into the report's "Run Info"
drawer.
---
1. ``ReportConfig``
-------------------
.. code-block:: python
from coco_pipe.report.config import ReportConfig
ReportConfig(
title="EEG Decoding — Cohort A",
author="Hamza Abdelhedi",
description="3-class motor imagery, 12 subjects, LOSO CV.",
run_params={
"experiment_id": "exp_007",
"max_iter": 500,
"scaler": "StandardScaler",
},
# provenance defaults to ProvenanceConfig.from_env()
)
.. list-table::
:header-rows: 1
* - Field
- Description
* - ``title``
- Report title. Defaults to ``"CoCo Analysis Report (YYYY-MM-DD)"``.
* - ``author``
- Optional author name.
* - ``description``
- Optional one-line summary.
* - ``provenance``
- :class:`ProvenanceConfig`; defaults to ``ProvenanceConfig.from_env()`` which captures git hash + package versions + python/OS at instantiation.
* - ``run_params``
- Free-form dict of analysis parameters, exposed in the Run Info drawer.
Pydantic ``extra="allow"`` is set, so unknown fields are preserved on
the model (accessible via attribute access).
---
2. Passing Config to a Report
-----------------------------
Three styles, ordered by ceremony:
.. code-block:: python
# 1. Title only (most common)
Report(title="Quick", config={"experiment": "demo"})
# 2. Dict — pydantic-coerced to ReportConfig
Report(config={
"title": "Detailed",
"description": "Quarterly QC",
"run_params": {"experiment": "Q1", "subjects": 24},
})
# 3. Typed ReportConfig
cfg = ReportConfig(title="Typed", description="QC")
Report(config=cfg)
The :meth:`Report._resolve_config` helper handles the coercion. If
the dict is malformed (raises pydantic ``ValidationError``), the
report falls back to a minimal ``ReportConfig(title=title,
run_params=config)`` — the report still renders rather than failing
hard.
When the config dict carries its own ``"title"``, that value wins
over the ``title=`` constructor argument.
---
3. ``ProvenanceConfig``
-----------------------
Captures runtime metadata automatically:
.. list-table::
:header-rows: 1
* - Field
- Source
* - ``source``
- User-tagged data source (e.g., ``"BIDS"``, ``"Tabular"``). Default ``"Unknown"``.
* - ``git_hash``
- Output of ``git rev-parse --short HEAD`` (``"Unknown"`` outside a git repo).
* - ``timestamp_utc``
- UTC ISO-ish timestamp at config-instantiation.
* - ``command``
- Original ``sys.argv`` (truncated for safety).
* - ``python_version``
- ``platform.python_version()``.
* - ``os_platform``
- ``platform.platform()``.
* - ``coco_pipe_version``
- ``importlib.metadata.version("coco-pipe")``.
* - ``versions``
- ``{package_name: version}`` for every imported scientific package detected at capture time.
3.1 Auto-capture
~~~~~~~~~~~~~~~~
.. code-block:: python
from coco_pipe.report.config import ProvenanceConfig
prov = ProvenanceConfig.from_env(source="BIDS")
prov.git_hash, prov.python_version, prov.coco_pipe_version
This is what :meth:`Report.__init__` calls when no provenance is
passed. The captured snapshot is then frozen onto the report — even
if the working tree changes after rendering.
3.2 Manual override
~~~~~~~~~~~~~~~~~~~
.. code-block:: python
prov = ProvenanceConfig(
source="cluster-shared",
git_hash="a1b2c3d",
command="python train.py --cohort A",
)
cfg = ReportConfig(title="Override", provenance=prov)
report = Report(config=cfg)
Useful for batch runs where the auto-captured ``command`` would be
the orchestrator's command (e.g., ``snakemake``) rather than the
actual analysis invocation.
---
4. Where Config Shows Up in the Report
--------------------------------------
.. list-table::
:header-rows: 1
* - Field
- Location in rendered HTML
* - ``title``
- ```` and the header brand panel.
* - ``provenance.timestamp_utc``
- Header subheading and Run Info > Environment.
* - ``provenance.git_hash``
- Header summary "Git" column and Run Info.
* - ``provenance.python_version``
- Run Info > Environment.
* - ``provenance.os_platform``
- Run Info > Environment.
* - ``provenance.coco_pipe_version``
- Run Info > Environment and the footer.
* - ``run_params``
- Run Info > Configuration (rendered as syntax- highlighted JSON).
* - ``provenance.command``
- Run Info > Execution Command + footer.
The Run Info drawer slides in from the right when the user clicks the
"Run Info" button in the header.
---
5. Serializing and Inspecting Config
------------------------------------
Both models are standard pydantic — ``model_dump()``,
``model_dump_json()``, ``model_validate()`` all work.
.. code-block:: python
from coco_pipe.report.config import ReportConfig
cfg = ReportConfig(title="serializable", run_params={"k": 1})
payload = cfg.model_dump_json(indent=2)
restored = ReportConfig.model_validate_json(payload)
The rendered report also embeds the full config as syntax-highlighted
JSON in the Run Info drawer, so the report itself is its own
configuration audit trail.
---
6. Custom Fields
----------------
Because ``extra="allow"`` is set on both models, any extra fields on a
config dict are kept:
.. code-block:: python
rep = Report(config={"title": "Demo", "extra_field": 42})
rep.config.extra_field # -> 42
Use this sparingly — extras don't get a dedicated section in the
template and just live on the pydantic instance. For analysis
parameters, prefer the typed ``run_params`` field, which gets a real
home in the Run Info drawer.