Building Reports#

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 Report is a container of Section objects. Each section is itself a container of Element instances — images, Plotly figures, tables, code blocks, callouts, accordions, and so on (see Elements and Assets).

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 <div data-id="…" class="lazy-plot"></div>; 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:

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

    <html>...
      <script type="application/json" id="report-payload">{payload}</script>
      <script>
        const REPORT_DATA = JSON.parse(pako.inflate(atob(...)));
        // lazy-plot div hydration on intersection
      </script>
    </html>

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 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 Report constructor takes asset_urls= controlling how they’re sourced:

asset_urls argument

Behavior

None (default)

<script src="…CDN URL…"> — requires network.

dict

Override one or more URLs (e.g. self-hosted copies).

"inline"

Download the bundles once (cached at ~/.cache/coco-pipe/report-assets/), then inline them in <script> tags. Fully offline.

See JavaScript Asset Modes for the full asset story and the vendor_assets() helper for pre-warming the cache on an internet-connected machine.

4. Provenance Is Automatic, Not Optional#

Every Report captures a ProvenanceConfig at construction time:

  • git_hash — current commit (or "Unknown" outside a git repo).

  • python_version, os_platform — runtime metadata.

  • coco_pipe_version — installed package version.

  • versions — versions of every imported scientific package.

  • timestamp_utc — when the report was assembled.

  • command — the command-line invocation that produced it.

Provenance lives under the Run Info drawer in the rendered HTML and is also serialized into the on-page payload. To audit a report after the fact, callers don’t need the original code — the report carries enough metadata to reproduce the environment.

You can override or extend any field via the config argument to Report; see Configuration and Provenance.

5. Section Adders Are Functions, Bound at Import Time#

Each add_decoding_* / add_reduction_* method on Report is a top-level function in coco_pipe.report.decoding or coco_pipe.report.dim_reduction, attached to Report at package import:

# In coco_pipe/report/decoding.py:
def add_decoding_overview(self: Report, result, *, name="Overview"):
    ...
Report.add_decoding_overview = add_decoding_overview

This pattern keeps core.py free of domain-specific imports (no circular dependency on viz or decoding) while preserving the fluent report.add_decoding_overview(...) API. The standalone functions remain importable for testing.

See Custom Elements and Adders for the same pattern applied to your own custom section adders.

6. Data-Quality Findings Are First-Class#

When a DataContainer is added via Report.add_container, the section is automatically populated with quality findings:

  • Missingness (NaN fraction)

  • Flatline detection (zero-variance signals)

  • Outliers (z-score thresholding)

  • Constant columns

Each finding is a CheckResult with a severity and status ("OK" / "WARN" / "FAIL"). The section’s overall status reflects the worst finding (FAIL > WARN > OK). The sidebar TOC then surfaces sections needing attention with colored dots.

See Data-Quality Checks and Findings.

7. Three Audiences, One API#

The module is structured so each audience uses just enough of it:

Audience

Surface

Run-and-save user

from_experiment_result(...) or sibling factories. One call, one .html file.

Composer

Report() + fluent add_* methods. Custom titles, multiple sections, custom themes.

Extender

Subclass Element, attach functions to Report, or override Jinja partials. See Custom Elements and Adders and Templates and Layout Customization.

Most users stay at the first level. The lower levels are available when needed and don’t require the upper levels to know about them.

Section and Report#

Section and Report are the two container classes that hold the tree of HTML elements assembled by every other module.

1. Quick Reference#

from coco_pipe.report import Report, Section
from coco_pipe.report.elements import PlotlyElement, TableElement

report = Report(title="My Analysis")

sec = Section(title="Performance", description="CV scores per fold")
sec.add_element(TableElement(scores_df))
sec.add_element(PlotlyElement(roc_figure))
report.add_section(sec)

report.save("performance.html")

Every method on Report and Section returns self so they chain:

Report("Demo").add_container(c).add_decoding_overview(result).save("r.html")

2. Section#

A logical group of elements with a title, optional icon, optional description, tags (for sidebar filtering), and a status ("OK" / "WARN" / "FAIL").

Section(
    title="Quality Findings",
    icon="⚠",
    tags=["quality", "preprocessing"],
    status="WARN",
    description="2 channels flatlined during run 02.",
    code="reducer.fit(X)",  # rendered behind a "Source" modal
    metadata={"input": "X.npy", "rows": 1024},
)

Methods

add_element(element)

Append any Element or raw HTML string.

add_columns(elements, cols=?)

Render children in a CSS grid row.

add_finding(check_result)

Attach a CheckResult; section status auto-upgrades to WARN / FAIL.

render() -> str

Render the section to HTML (called by Report).

Status semantics

FAIL is sticky. Once a section’s status is FAIL it cannot be downgraded by a later WARN. The sidebar marks any WARN / FAIL section with a colored dot and surfaces an “Attention Needed” summary at the bottom of the sidebar.

3. Report#

The top-level container. Owns the asset URLs, theme, configuration, provenance metadata, and the section list.

Report(
    title="CoCo Analysis Report",
    config={"experiment_name": "demo"},   # dict or ReportConfig
    theme="paper",                        # "paper" | "notebook" | "poster"
    asset_urls=None,                      # None | dict | "inline"
)

Construction

The constructor delegates to three helpers (kept lazy and side-effect-free in their own right):

_apply_theme(theme)

Sets matplotlib rcParams and the Plotly coco template for any figures rendered during the session.

_resolve_assets(asset_urls)

Returns the (urls, mode) tuple that drives <script src=…> versus inlined <script>…</script>. See JavaScript Asset Modes.

_resolve_config(config)

Coerces config into a ReportConfig, honoring an explicit title argument.

Section adders

Method

Use

Report.add_section(section)()

Append a pre-built Section. IDs are auto-uniqued on collision.

Report.add_figure(fig, caption=)()

Shortcut: wraps a matplotlib / Plotly figure in its own section.

Report.add_container(container, ...)()

Inspect a DataContainer: dimensions, coordinates, missingness, flatline / outlier checks, sample histograms.

Report.add_raw_preview(data, name=)()

Interactive Plotly preview for raw arrays or DataContainer X.

Report.add_summary_card(metrics)()

Top-of-report key/value stat strip (e.g., {"Accuracy": 0.83, "Folds": 5}).

Report.add_markdown(text)()

Append a markdown block (inherited from ContainerElement).

Report.add_element(element)()

Append any Element outside a section.

Domain-specific adders (add_decoding_*, add_reduction_*, add_reduction, add_comparison) are bound at import time from decoding and dim_reduction — see Decoding Sections and Dimensionality-Reduction Sections.

Rendering

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 Scientific Concepts and Principles).

4. Common Patterns#

4.1 One-shot save#

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#

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#

report  # _repr_html_ auto-displays the rendered HTML in Jupyter

4.4 Serve over HTTP#

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#

Action

Behavior

Missing reducer get_summary

Report.add_reduction() raises TypeError.

Empty comparison frame

Report.add_comparison() raises ValueError.

Broken DataContainer

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.

Factories and Assembly#

coco_pipe.report.api exposes seven top-level helpers — six from_* factories that produce a populated Report from a domain object, plus merge_reports() for combining existing reports.

These are convenience entry points. Internally they call the same add_* methods documented in Section and Report, Decoding Sections, and Dimensionality-Reduction Sections.

1. Factory at a Glance#

Factory

Input Output sections

from_container()

DataContainer Data overview + optional raw preview

from_bids()

BIDS root directory Data overview from loaded container

from_tabular()

Path to CSV / parquet / etc. Data overview from loaded container

from_embeddings()

X_emb array Standalone embedding section

from_reductions()

list of scored DimReduction One section per reducer + optional comparison

from_experiment_result()

ExperimentResult Full decoding 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 DataContainer as a report.

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 Data-Quality Checks and Findings), and a sample histogram.

2.2 from_bids#

Wraps coco_pipe.io.load_data() in BIDS mode + from_container.

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 coco_pipe.io.load_data() in tabular mode + from_container.

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 DimReduction to ship alongside.

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.

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 make_reduction_report() plus an extra add_container call.

3.2 from_experiment_result#

Builds a full decoding report from an ExperimentResult.

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 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.

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:

merged = merge_reports(r1, r2)
merged.theme = "poster"
merged.asset_mode = "inline"      # rebuild urls accordingly with _resolve_assets

5. Argument Conventions#

Argument

Purpose

title

Report title; shown in the header and the browser tab.

config

Dict of free-form parameters. Stored on the ReportConfig for the Run Info drawer.

theme

"paper" (default) / "notebook" / "poster". Mirrors coco_pipe.viz.theme modes.

asset_urls

None / dict / "inline". See JavaScript Asset Modes.

output_path

Convenience: when set, the factory also calls 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.

Configuration and Provenance#

coco_pipe.report.config defines two pydantic models that govern how a report identifies itself and the environment that produced it:

  • ReportConfig — title, author, description, run parameters.

  • 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#

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()
)

Field

Description

title

Report title. Defaults to "CoCo Analysis Report (YYYY-MM-DD)".

author

Optional author name.

description

Optional one-line summary.

provenance

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:

# 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 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:

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#

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 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#

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#

Field

Location in rendered HTML

title

<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.

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:

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.