Validation case status dashboard

At-a-glance state of every validation/**/*.nassu.yaml case, derived from the notebook and config files themselves - no simulation results are read. It tells you which cases still need (re)running here, when each notebook was last run, and the nassu version/commit it was executed at, so you do not have to open notebooks one by one.

Verdict legend - RUN the notebook has no outputs (never executed here), RERUN the config changed after the notebook was last run, BEHIND the notebook was executed on an older commit than HEAD, NO_NB the case has no analysis notebook, OK up to date. The local_results column is a hint only (is there a results/ folder on this machine) and never affects the verdict - results are gitignored and local-only.

Run all cells. Re-run after you execute/save a case notebook to refresh its row.

[1]:
import datetime as dt
import json
import pathlib
import re
import subprocess
from dataclasses import dataclass, field

import pandas as pd

UTC = dt.timezone.utc

# Repo root = nearest ancestor containing pyproject.toml; validation/ beneath it.
REPO = pathlib.Path.cwd()
while REPO != REPO.parent and not (REPO / "pyproject.toml").exists():
    REPO = REPO.parent
VALIDATION = REPO / "validation"

_COMMIT_RE = re.compile(r"Commit hash:\s*([0-9a-f]{7,40})")
_VERSION_RE = re.compile(r"Version:\s*([0-9][^\s]*)")

# Each verdict keyword -> (background, text) hex pair. The text colour is set
# explicitly (dark on light pastels) so the table reads correctly under VS Code's
# dark or light notebook theme, not just whatever the theme default text is.
VERDICT_STYLE = {
    "RUN": ("#fca5a5", "#3f0a0a"),  # red: notebook has no outputs (never run here)
    "RERUN": ("#fcd34d", "#3a2c00"),  # amber: config changed since last run
    "BEHIND": ("#fde68a", "#3a2c00"),  # light amber: ran on an older commit
    "NO_NB": ("#e5e7eb", "#1f2937"),  # grey: no analysis notebook
    "OK": ("#86efac", "#06351a"),  # green
}


def sh(*args):
    out = subprocess.run(args, cwd=REPO, capture_output=True, text=True, check=False)
    return out.stdout.strip()


def git_head():
    return sh("git", "rev-parse", "HEAD")


def git_last_commit(path):
    """(short hash, committer datetime UTC, subject) of the last commit on path."""
    rel = path.relative_to(REPO).as_posix()
    line = sh("git", "log", "-1", "--format=%h\t%cI\t%s", "--", rel)
    if not line:
        return ("untracked", None, "")
    h, iso, *subj = line.split("\t")
    return (h, dt.datetime.fromisoformat(iso).astimezone(UTC), subj[0] if subj else "")


def commits_behind(run_commit, head):
    """How many commits HEAD is ahead of run_commit (0 at HEAD, None if unknown)."""
    if not run_commit:
        return None
    if head.startswith(run_commit) or run_commit.startswith(head):
        return 0
    reachable = subprocess.run(
        ["git", "merge-base", "--is-ancestor", run_commit, head],
        cwd=REPO,
        capture_output=True,
    )
    if reachable.returncode != 0:
        return None
    n = sh("git", "rev-list", "--count", f"{run_commit}..{head}")
    return int(n) if n.isdigit() else None


def mtime_utc(path):
    return dt.datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)


def fmt_dt(d):
    return d.astimezone().strftime("%Y-%m-%d %H:%M") if d else "-"
[2]:
@dataclass
class NotebookState:
    path: pathlib.Path
    has_outputs: bool
    mtime: dt.datetime
    ran_version: str | None  # parsed from the notebook's own Version cell output
    ran_commit: str | None


@dataclass
class CaseState:
    folder: pathlib.Path
    yamls: list = field(default_factory=list)
    yaml_commits: dict = field(default_factory=dict)
    notebooks: list = field(default_factory=list)
    has_local_results: bool = False  # hint only; never drives the verdict

    @property
    def newest_config_change(self):
        cand = []
        for y in self.yamls:
            cand.append(mtime_utc(y))
            _, when, _ = self.yaml_commits.get(y, (None, None, None))
            if when:
                cand.append(when)
        return max(cand) if cand else None

    @property
    def newest_notebook_save(self):
        saved = [nb.mtime for nb in self.notebooks if nb.has_outputs]
        return max(saved) if saved else None


def parse_notebook(path):
    """Executed state + the version/commit the notebook printed from read_info()."""
    has_outputs = False
    out_text = []
    try:
        with path.open() as fh:
            nb = json.load(fh)
        for cell in nb.get("cells", []):
            if cell.get("cell_type") != "code":
                continue
            for o in cell.get("outputs", []):
                has_outputs = True
                txt = o.get("text") or (o.get("data", {}) or {}).get("text/plain", "")
                out_text.append("".join(txt) if isinstance(txt, list) else str(txt))
    except (json.JSONDecodeError, OSError):
        pass
    joined = "".join(out_text)
    cm = _COMMIT_RE.search(joined)
    vm = _VERSION_RE.search(joined)
    return NotebookState(
        path,
        has_outputs,
        mtime_utc(path),
        vm.group(1) if vm else None,
        cm.group(1)[:10] if cm else None,
    )


def collect():
    """One CaseState per leaf folder containing at least one .nassu.yaml."""
    folders = sorted({p.parent for p in VALIDATION.rglob("*.nassu.yaml")})
    cases = []
    for folder in folders:
        cs = CaseState(folder=folder)
        for y in sorted(folder.glob("*.nassu.yaml")):
            cs.yamls.append(y)
            cs.yaml_commits[y] = git_last_commit(y)
        cs.notebooks = [parse_notebook(nb) for nb in sorted(folder.glob("*.ipynb"))]
        results = folder / "results"
        cs.has_local_results = results.is_dir() and any(results.rglob("info.yaml"))
        cases.append(cs)
    return cases


def verdict(cs, head):
    """Status from the notebook + config only (no results dependency)."""
    if not cs.notebooks:
        return ["NO_NB"]
    executed = [nb for nb in cs.notebooks if nb.has_outputs]
    if not executed:
        return ["RUN"]
    flags = []
    if (
        cs.newest_config_change
        and cs.newest_notebook_save
        and cs.newest_config_change > cs.newest_notebook_save
    ):
        flags.append("RERUN")
    behind = [b for nb in executed if (b := commits_behind(nb.ran_commit, head)) not in (None, 0)]
    if behind and "RERUN" not in flags:
        flags.append("BEHIND")
    return flags or ["OK"]


def case_rows(cases, head):
    """One flat dict per case for the table."""
    rows = []
    for cs in cases:
        flags = verdict(cs, head)
        executed = [nb for nb in cs.notebooks if nb.has_outputs]
        versions = sorted({nb.ran_version for nb in executed if nb.ran_version})
        commits = [nb.ran_commit for nb in executed if nb.ran_commit]
        behind = [b for c in commits if (b := commits_behind(c, head)) is not None]
        cfg_dates = sorted(w for y in cs.yamls if (w := cs.yaml_commits[y][1]))
        rows.append(
            {
                "category": cs.folder.relative_to(VALIDATION).parts[0],
                "case": cs.folder.relative_to(VALIDATION).as_posix(),
                "verdict": " ".join(flags),
                "needs_action": flags != ["OK"],
                "config_changed": fmt_dt(cfg_dates[-1]) if cfg_dates else "-",
                "notebooks_executed": f"{len(executed)}/{len(cs.notebooks)}"
                if cs.notebooks
                else "-",
                "notebook_saved": fmt_dt(cs.newest_notebook_save),
                "ran_version": ",".join(versions) if versions else "-",
                "ran_commit": commits[0] if commits else "-",
                "commits_behind_head": max(behind) if behind else 0,
                "local_results": "yes" if cs.has_local_results else "no",
            }
        )
    return rows


cases = collect()
head = git_head()
print(f"HEAD {head[:10]}  -  {len(cases)} validation cases")
HEAD 9762cc51b8  -  26 validation cases
[3]:
def verdict_css(val):
    pair = VERDICT_STYLE.get(str(val).split(" ")[0])
    if not pair:
        return ""
    bg, fg = pair
    return f"background-color: {bg}; color: {fg}; font-weight: 600"


def style_table(df):
    return (
        df.style.map(verdict_css, subset=["verdict"])
        .set_properties(**{"text-align": "left"})
        .hide(axis="index")
    )


df = pd.DataFrame(case_rows(cases, head))
style_table(df)
[3]:
category case verdict needs_action config_changed notebooks_executed notebook_saved ran_version ran_commit commits_behind_head local_results
analytical analytical/01_couette_flow BEHIND True 2026-06-24 17:08 1/1 2026-06-29 14:41 2.0.1a0 ab62e54213 4 yes
analytical analytical/02_poiseuille_channel_flow BEHIND True 2026-06-24 17:08 4/4 2026-06-29 14:41 2.0.1a0 20647b9f11 240 yes
analytical analytical/03_poiseuille_pipe_flow RERUN True 2026-06-24 17:08 2/2 2026-06-24 09:13 2.0.1a0 393bce29ec 239 yes
analytical analytical/04_taylor_green_vortex_2d BEHIND True 2026-06-24 17:08 1/1 2026-06-29 14:41 2.0.1a0 7c0d788ec9 235 yes
buoyant_flows buoyant_flows/01_lowmach_bubble RUN True 2026-06-24 17:08 0/1 - - - 0 no
buoyant_flows buoyant_flows/02_sandia_helium_plume NO_NB True 2026-06-24 17:08 - - - - 0 no
buoyant_flows buoyant_flows/03_steckler_room_fire RUN True 2026-06-24 17:08 0/1 - - - 0 no
buoyant_flows buoyant_flows/04_sandia_methane_pool_fire NO_NB True 2026-06-24 17:08 - - - - 0 no
calibration calibration/neq_wm_pressure_filter/reference NO_NB True 2026-06-24 17:08 - - - - 0 no
external_aero external_aero/01_flow_over_sphere BEHIND True 2026-06-24 19:23 3/3 2026-06-25 02:48 2.0.1a0 74086fcfe9 218 yes
external_aero external_aero/02_flow_over_cylinder RERUN True 2026-06-24 17:08 2/2 2026-06-24 13:00 2.0.1a0 74086fcfe9 218 yes
external_aero external_aero/03_flat_plate_boundary_layer RUN True 2026-06-24 19:35 0/1 - - - 0 no
porous_media porous_media/01_flow_through_trees BEHIND True 2026-06-24 17:08 1/1 2026-06-29 14:43 2.0.1a0 74086fcfe9 218 yes
porous_media porous_media/02_porous_pipe_flow BEHIND True 2026-06-24 17:08 1/1 2026-06-29 14:43 2.0.1a0 80a1135356 213 yes
scalar_transport scalar_transport/01_passive_scalar_transport BEHIND True 2026-06-24 17:08 2/2 2026-06-29 14:43 2.0.1a0 80a1135356 213 yes
scalar_transport scalar_transport/02_turb_channel_passive_scalar BEHIND True 2026-06-25 01:49 1/1 2026-06-25 11:53 2.0.1a0 ce9eac31c9 100 yes
thermal thermal/01_differentially_heated_cavity BEHIND True 2026-06-24 17:08 1/1 2026-06-29 14:44 2.0.1a0 80a1135356 213 yes
thermal thermal/02_heated_cylinder BEHIND True 2026-06-24 17:08 1/1 2026-06-29 14:44 2.0.1a0 80a1135356 213 yes
turbulence turbulence/01_taylor_green_vortex_3d BEHIND True 2026-06-24 17:08 1/1 2026-06-29 14:45 2.0.1a0 50dc5112a0 199 yes
turbulence turbulence/02_turbulent_channel_flow BEHIND True 2026-06-24 17:08 3/3 2026-06-29 14:45 2.0.1a0 50dc5112a0 199 yes
turbulence turbulence/03_turbulent_pipe_flow BEHIND True 2026-06-24 17:08 2/2 2026-06-29 14:45 1.6.17,2.0.1a0 09c9337ca1 2546 yes
turbulence turbulence/04_backward_step_flow RERUN True 2026-06-24 17:08 1/1 2026-06-17 11:14 1.6.8 9b7208ede6 2772 no
wind_engineering wind_engineering/01_atmospheric_boundary_layer RERUN True 2026-06-24 17:08 1/1 2026-06-17 02:09 - - 0 yes
wind_engineering wind_engineering/02_flow_over_wall_mounted_cube RERUN True 2026-06-24 17:08 2/2 2026-06-22 15:31 1.6.51 8df9b1583e 1305 yes
wind_engineering wind_engineering/03_cedval_building RUN True 2026-06-24 17:08 0/1 - - - 0 no
wind_engineering wind_engineering/04_codasc_canyon RUN True 2026-06-24 17:08 0/1 - - - 0 no

Cases needing action

Everything that is not plain OK - your worklist.

[4]:
todo = df[df["needs_action"]].drop(columns=["needs_action"])
print(f"{len(todo)} of {len(df)} cases need action")
style_table(todo) if len(todo) else print("All cases OK \U0001f389")
26 of 26 cases need action
[4]:
category case verdict config_changed notebooks_executed notebook_saved ran_version ran_commit commits_behind_head local_results
analytical analytical/01_couette_flow BEHIND 2026-06-24 17:08 1/1 2026-06-29 14:41 2.0.1a0 ab62e54213 4 yes
analytical analytical/02_poiseuille_channel_flow BEHIND 2026-06-24 17:08 4/4 2026-06-29 14:41 2.0.1a0 20647b9f11 240 yes
analytical analytical/03_poiseuille_pipe_flow RERUN 2026-06-24 17:08 2/2 2026-06-24 09:13 2.0.1a0 393bce29ec 239 yes
analytical analytical/04_taylor_green_vortex_2d BEHIND 2026-06-24 17:08 1/1 2026-06-29 14:41 2.0.1a0 7c0d788ec9 235 yes
buoyant_flows buoyant_flows/01_lowmach_bubble RUN 2026-06-24 17:08 0/1 - - - 0 no
buoyant_flows buoyant_flows/02_sandia_helium_plume NO_NB 2026-06-24 17:08 - - - - 0 no
buoyant_flows buoyant_flows/03_steckler_room_fire RUN 2026-06-24 17:08 0/1 - - - 0 no
buoyant_flows buoyant_flows/04_sandia_methane_pool_fire NO_NB 2026-06-24 17:08 - - - - 0 no
calibration calibration/neq_wm_pressure_filter/reference NO_NB 2026-06-24 17:08 - - - - 0 no
external_aero external_aero/01_flow_over_sphere BEHIND 2026-06-24 19:23 3/3 2026-06-25 02:48 2.0.1a0 74086fcfe9 218 yes
external_aero external_aero/02_flow_over_cylinder RERUN 2026-06-24 17:08 2/2 2026-06-24 13:00 2.0.1a0 74086fcfe9 218 yes
external_aero external_aero/03_flat_plate_boundary_layer RUN 2026-06-24 19:35 0/1 - - - 0 no
porous_media porous_media/01_flow_through_trees BEHIND 2026-06-24 17:08 1/1 2026-06-29 14:43 2.0.1a0 74086fcfe9 218 yes
porous_media porous_media/02_porous_pipe_flow BEHIND 2026-06-24 17:08 1/1 2026-06-29 14:43 2.0.1a0 80a1135356 213 yes
scalar_transport scalar_transport/01_passive_scalar_transport BEHIND 2026-06-24 17:08 2/2 2026-06-29 14:43 2.0.1a0 80a1135356 213 yes
scalar_transport scalar_transport/02_turb_channel_passive_scalar BEHIND 2026-06-25 01:49 1/1 2026-06-25 11:53 2.0.1a0 ce9eac31c9 100 yes
thermal thermal/01_differentially_heated_cavity BEHIND 2026-06-24 17:08 1/1 2026-06-29 14:44 2.0.1a0 80a1135356 213 yes
thermal thermal/02_heated_cylinder BEHIND 2026-06-24 17:08 1/1 2026-06-29 14:44 2.0.1a0 80a1135356 213 yes
turbulence turbulence/01_taylor_green_vortex_3d BEHIND 2026-06-24 17:08 1/1 2026-06-29 14:45 2.0.1a0 50dc5112a0 199 yes
turbulence turbulence/02_turbulent_channel_flow BEHIND 2026-06-24 17:08 3/3 2026-06-29 14:45 2.0.1a0 50dc5112a0 199 yes
turbulence turbulence/03_turbulent_pipe_flow BEHIND 2026-06-24 17:08 2/2 2026-06-29 14:45 1.6.17,2.0.1a0 09c9337ca1 2546 yes
turbulence turbulence/04_backward_step_flow RERUN 2026-06-24 17:08 1/1 2026-06-17 11:14 1.6.8 9b7208ede6 2772 no
wind_engineering wind_engineering/01_atmospheric_boundary_layer RERUN 2026-06-24 17:08 1/1 2026-06-17 02:09 - - 0 yes
wind_engineering wind_engineering/02_flow_over_wall_mounted_cube RERUN 2026-06-24 17:08 2/2 2026-06-22 15:31 1.6.51 8df9b1583e 1305 yes
wind_engineering wind_engineering/03_cedval_building RUN 2026-06-24 17:08 0/1 - - - 0 no
wind_engineering wind_engineering/04_codasc_canyon RUN 2026-06-24 17:08 0/1 - - - 0 no
[5]:
# Verdict tally across all cases.
from collections import Counter

tally = Counter(f for v in df["verdict"] for f in v.split(" "))
pd.Series(tally, name="cases").sort_values(ascending=False).to_frame()
[5]:
cases
BEHIND 13
RERUN 5
RUN 5
NO_NB 3