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 |