Merge energy-conservation-poc into phase2-secondary-prediction
Brings the energy-conservation PoC work (dwarf CLI unification, dwarf
status improvements, predict --comment, ODE-step comparison scripts,
predict-parquet-only analysis refactor) onto the Phase 2 branch.
Conflict resolution:
- giant/analysis.py: took the energy-conservation-poc version wholesale.
That branch deliberately removed the live checkpoint+sampler diagnostics
path (ModelBundle/load_model_bundle/make_val_loader/collect_samples) in
favor of reading `giant predict --coord local` parquet output. Phase 2's
only edits to this file adapted the removed path to the new dataset API,
so nothing Phase-2-specific is lost; no external code called those funcs.
Fixes for pre-existing breakage surfaced by the merge (both predate it):
- giant/cli.py: predict's `_process` unpacked build_features into 5 values,
but Phase 2 made it return 8 (added n_sec/sec_cont/sec_pdg_idx). Expanded
the unpack; `giant predict --coord local` would have crashed otherwise.
- tests/test_steps_to_parquet.py: Phase 2 renamed _add_secondary_energy ->
_add_secondary_attributes without updating this test. Renamed the calls
and extended the fixture with the pdg/pre_d{x,y,z} columns the expanded
function reads; e_sec assertions unchanged.
- analysis/compare_ode_steps_energy_conservation.py: E731 lambda assignment
(added in the un-linted final PoC commit) rewritten as a def.
ruff, ty, and pytest (179 passed) all green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+526
-223
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cut a new raw generation or processed schema version for the geant_steps
|
||||
dataset tree (see scripts/migrate_geant_steps.py for the layout):
|
||||
|
||||
@@ -7,30 +6,67 @@ dataset tree (see scripts/migrate_geant_steps.py for the layout):
|
||||
pools/<detector>/<pool>.manifest
|
||||
|
||||
`gen` bumps when the underlying ROOT changes (geometry/physics-list/macro).
|
||||
`schema` bumps when the parquet export (steps_to_parquet.py or similar)
|
||||
changes, and is scoped to its gen — a new gen always starts back at schema1.
|
||||
`schema` bumps when the parquet export (`dwarf convert` or similar) changes,
|
||||
and is scoped to its gen — a new gen always starts back at schema1.
|
||||
|
||||
Creates the new (empty) target directory and appends a dated, reasoned entry
|
||||
to VERSIONS.md. Defaults to a dry run; pass --execute to apply.
|
||||
to VERSIONS.md. Defaults to a dry run; pass execute=True to apply.
|
||||
|
||||
Usage:
|
||||
bump_dataset_version.py bump-gen --kind steps --reason "switched EM physics list"
|
||||
bump_dataset_version.py bump-schema --kind steps --gen gen1 --reason "added e_sec column"
|
||||
bump_dataset_version.py update-manifest pools/pbwo4/full.manifest [--schema schema2]
|
||||
bump_dataset_version.py create-manifest --output pools/pbwo4/train.manifest a.parquet b.parquet
|
||||
bump_dataset_version.py status
|
||||
See `uv run dwarf bump-gen/bump-schema/update-manifest/create-manifest/status
|
||||
--help` for the CLI.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
GEN_RE = re.compile(r"^gen(\d+)$")
|
||||
SCHEMA_RE = re.compile(r"^schema(\d+)$")
|
||||
|
||||
# Match the log lines written by apply_bump()/plan_bump_gen()/plan_bump_schema():
|
||||
# - `gen2` (kind=steps) — 2026-01-01 — reason text (by)
|
||||
# - `gen2`/`schema2` (kind=steps) — 2026-01-01 — reason text (by)
|
||||
VERSIONS_SCHEMA_LINE_RE = re.compile(
|
||||
r"^- `(?P<gen>gen\d+)`/`(?P<schema>schema\d+)` \(kind=(?P<kind>[\w-]+)\)"
|
||||
r" — \d{4}-\d{2}-\d{2} — (?P<reason>.+)$"
|
||||
)
|
||||
VERSIONS_GEN_LINE_RE = re.compile(
|
||||
r"^- `(?P<gen>gen\d+)` \(kind=(?P<kind>[\w-]+)\) — \d{4}-\d{2}-\d{2} — (?P<reason>.+)$"
|
||||
)
|
||||
|
||||
# One color per tree level in `dwarf status` output, so the eye can jump
|
||||
# straight to e.g. "all the schema rows" or "all the totals".
|
||||
_LEVEL_COLORS = {
|
||||
"kind": "\033[1;36m", # bold cyan — top-level kind/ header
|
||||
"gen": "\033[1;33m", # bold yellow — genN row + kind total
|
||||
"bucket": "\033[34m", # blue — raw/processed subtotals
|
||||
"schema": "\033[32m", # green — schemaN rows
|
||||
"root": "\033[1;35m", # bold magenta — derived/, pools/, grand total
|
||||
"reason": "\033[2m", # dim — VERSIONS.md reason extract under gen/schema rows
|
||||
}
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
def _use_color() -> bool:
|
||||
return sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
||||
|
||||
|
||||
def _colorize(text: str, level: str) -> str:
|
||||
if not _use_color():
|
||||
return text
|
||||
return f"{_LEVEL_COLORS[level]}{text}{_RESET}"
|
||||
|
||||
|
||||
def _find_component_idx(abs_path: Path, pattern: re.Pattern) -> int | None:
|
||||
"""Return the index of the first path component matching *pattern*, or None."""
|
||||
for i, part in enumerate(abs_path.parts):
|
||||
if pattern.match(part):
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def _max_index(parent: Path, pattern: re.Pattern) -> int:
|
||||
"""Highest N across child dir names matching *pattern* (0 if none/missing)."""
|
||||
@@ -56,18 +92,28 @@ def _git_user_name() -> str | None:
|
||||
|
||||
|
||||
def plan_bump_gen(
|
||||
root: Path, kind: str, reason: str, by: str | None, date: str
|
||||
root: Path,
|
||||
kind: str,
|
||||
reason: str,
|
||||
by: str | None,
|
||||
date: str,
|
||||
target: str | None = None,
|
||||
) -> tuple[list[Path], str]:
|
||||
"""New gen tag is one past the highest seen under raw/ or processed/ for *kind*
|
||||
— checking both, since a gen can exist in one tree before the other catches up."""
|
||||
next_gen = (
|
||||
max(
|
||||
_max_index(root / "raw" / kind, GEN_RE),
|
||||
_max_index(root / "processed" / kind, GEN_RE),
|
||||
"""New gen tag is one past the highest seen under raw/ or processed/ for *kind*,
|
||||
or *target* if explicitly provided."""
|
||||
if target is not None:
|
||||
if not GEN_RE.match(target):
|
||||
raise SystemExit(f"error: --to must look like 'genN', got {target!r}")
|
||||
gen_tag = target
|
||||
else:
|
||||
next_gen = (
|
||||
max(
|
||||
_max_index(root / "raw" / kind, GEN_RE),
|
||||
_max_index(root / "processed" / kind, GEN_RE),
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
+ 1
|
||||
)
|
||||
gen_tag = f"gen{next_gen}"
|
||||
gen_tag = f"gen{next_gen}"
|
||||
new_dirs = [
|
||||
root / "raw" / kind / gen_tag,
|
||||
root / "processed" / kind / gen_tag / "schema1",
|
||||
@@ -78,7 +124,13 @@ def plan_bump_gen(
|
||||
|
||||
|
||||
def plan_bump_schema(
|
||||
root: Path, kind: str, gen_tag: str, reason: str, by: str | None, date: str
|
||||
root: Path,
|
||||
kind: str,
|
||||
gen_tag: str,
|
||||
reason: str,
|
||||
by: str | None,
|
||||
date: str,
|
||||
target: str | None = None,
|
||||
) -> tuple[list[Path], str]:
|
||||
if not GEN_RE.match(gen_tag):
|
||||
raise SystemExit(f"error: --gen must look like 'genN', got {gen_tag!r}")
|
||||
@@ -88,11 +140,18 @@ def plan_bump_schema(
|
||||
raise SystemExit(
|
||||
f"error: {gen_tag} doesn't exist yet for kind={kind} — run bump-gen first"
|
||||
)
|
||||
next_schema = _max_index(processed_gen_dir, SCHEMA_RE) + 1
|
||||
schema_tag = f"schema{next_schema}"
|
||||
if target is not None:
|
||||
if not SCHEMA_RE.match(target):
|
||||
raise SystemExit(f"error: --to must look like 'schemaN', got {target!r}")
|
||||
schema_tag = target
|
||||
else:
|
||||
next_schema = _max_index(processed_gen_dir, SCHEMA_RE) + 1
|
||||
schema_tag = f"schema{next_schema}"
|
||||
new_dirs = [processed_gen_dir / schema_tag]
|
||||
by_suffix = f" ({by})" if by else ""
|
||||
log_line = f"- `{gen_tag}`/`{schema_tag}` (kind={kind}) — {date} — {reason}{by_suffix}"
|
||||
log_line = (
|
||||
f"- `{gen_tag}`/`{schema_tag}` (kind={kind}) — {date} — {reason}{by_suffix}"
|
||||
)
|
||||
return new_dirs, log_line
|
||||
|
||||
|
||||
@@ -106,11 +165,176 @@ def apply_bump(root: Path, new_dirs: list[Path], log_line: str) -> None:
|
||||
f.write(log_line + "\n")
|
||||
|
||||
|
||||
def _du(path: Path) -> int:
|
||||
"""Total size in bytes of all regular files under *path* (0 if missing)."""
|
||||
if not path.is_dir():
|
||||
return 0
|
||||
total = 0
|
||||
for dirpath, _dirnames, filenames in os.walk(path):
|
||||
for name in filenames:
|
||||
fp = Path(dirpath) / name
|
||||
try:
|
||||
total += fp.stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def _count_files(path: Path) -> int:
|
||||
"""Total number of regular files under *path*, recursively (0 if missing)."""
|
||||
if not path.is_dir():
|
||||
return 0
|
||||
total = 0
|
||||
for _dirpath, _dirnames, filenames in os.walk(path):
|
||||
total += len(filenames)
|
||||
return total
|
||||
|
||||
|
||||
# Must match giant.data.loader.MANIFEST_SUFFIX.
|
||||
MANIFEST_SUFFIX = ".manifest"
|
||||
|
||||
|
||||
def _manifest_referenced_files(pools_root: Path) -> set[Path]:
|
||||
"""Resolved absolute paths of every file listed in any *.manifest under *pools_root*."""
|
||||
referenced: set[Path] = set()
|
||||
if not pools_root.is_dir():
|
||||
return referenced
|
||||
for manifest_path in pools_root.rglob(f"*{MANIFEST_SUFFIX}"):
|
||||
try:
|
||||
referenced.update(_resolve_manifest_files(manifest_path))
|
||||
except OSError:
|
||||
continue
|
||||
return referenced
|
||||
|
||||
|
||||
def _referenced_root_count(
|
||||
raw_gen_dir: Path, processed_gen_dir: Path
|
||||
) -> tuple[int, int]:
|
||||
"""(total .root files, count with a same-named .parquet under any schema) for one gen."""
|
||||
if not raw_gen_dir.is_dir():
|
||||
return 0, 0
|
||||
parquet_stems: set[tuple[str, str]] = set()
|
||||
if processed_gen_dir.is_dir():
|
||||
for schema_dir in processed_gen_dir.iterdir():
|
||||
if not schema_dir.is_dir():
|
||||
continue
|
||||
for detector_dir in schema_dir.iterdir():
|
||||
if not detector_dir.is_dir():
|
||||
continue
|
||||
for f in detector_dir.iterdir():
|
||||
if f.is_file() and f.suffix == ".parquet":
|
||||
parquet_stems.add((detector_dir.name, f.stem))
|
||||
|
||||
total = 0
|
||||
referenced = 0
|
||||
for detector_dir in raw_gen_dir.iterdir():
|
||||
if not detector_dir.is_dir():
|
||||
continue
|
||||
for f in detector_dir.iterdir():
|
||||
if f.is_file() and f.suffix == ".root":
|
||||
total += 1
|
||||
if (detector_dir.name, f.stem) in parquet_stems:
|
||||
referenced += 1
|
||||
return total, referenced
|
||||
|
||||
|
||||
def _referenced_parquet_count(
|
||||
schema_dir: Path, manifest_referenced: set[Path]
|
||||
) -> tuple[int, int]:
|
||||
"""(total .parquet files, count listed in at least one manifest) for one schema dir."""
|
||||
if not schema_dir.is_dir():
|
||||
return 0, 0
|
||||
total = 0
|
||||
referenced = 0
|
||||
for detector_dir in schema_dir.iterdir():
|
||||
if not detector_dir.is_dir():
|
||||
continue
|
||||
for f in detector_dir.iterdir():
|
||||
if f.is_file() and f.suffix == ".parquet":
|
||||
total += 1
|
||||
if f.resolve() in manifest_referenced:
|
||||
referenced += 1
|
||||
return total, referenced
|
||||
|
||||
|
||||
def _parse_versions(
|
||||
versions_path: Path,
|
||||
) -> tuple[dict[tuple[str, str], str], dict[tuple[str, str, str], str]]:
|
||||
"""Read VERSIONS.md and return (gen_reasons, schema_reasons) keyed by
|
||||
(kind, gen_tag) and (kind, gen_tag, schema_tag) respectively. Later entries
|
||||
for the same key win, since VERSIONS.md is append-only and chronological."""
|
||||
gen_reasons: dict[tuple[str, str], str] = {}
|
||||
schema_reasons: dict[tuple[str, str, str], str] = {}
|
||||
if not versions_path.is_file():
|
||||
return gen_reasons, schema_reasons
|
||||
for line in versions_path.read_text().splitlines():
|
||||
line = line.strip()
|
||||
m = VERSIONS_SCHEMA_LINE_RE.match(line)
|
||||
if m:
|
||||
schema_reasons[(m["kind"], m["gen"], m["schema"])] = m["reason"]
|
||||
continue
|
||||
m = VERSIONS_GEN_LINE_RE.match(line)
|
||||
if m:
|
||||
gen_reasons[(m["kind"], m["gen"])] = m["reason"]
|
||||
return gen_reasons, schema_reasons
|
||||
|
||||
|
||||
def _truncate(text: str, width: int = 72) -> str:
|
||||
text = text.strip()
|
||||
if len(text) <= width:
|
||||
return text
|
||||
return text[: width - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _human_size(n: int) -> str:
|
||||
size = float(n)
|
||||
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||
if size < 1024 or unit == "TB":
|
||||
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f} TB"
|
||||
|
||||
|
||||
_LABEL_WIDTH = 26
|
||||
_COUNT_WIDTH = 20
|
||||
_ROW_WIDTH = _LABEL_WIDTH + _COUNT_WIDTH + 10
|
||||
|
||||
|
||||
def _count_str(count: int, referenced: int | None = None) -> str:
|
||||
files = "file" if count == 1 else "files"
|
||||
if referenced is not None:
|
||||
return f"{count} {files} ({referenced} ref)"
|
||||
return f"{count} {files}"
|
||||
|
||||
|
||||
def _row(
|
||||
label: str,
|
||||
size_bytes: int,
|
||||
indent: int = 0,
|
||||
level: str | None = None,
|
||||
count: int | None = None,
|
||||
referenced: int | None = None,
|
||||
) -> str:
|
||||
text = " " * indent + label
|
||||
count_str = _count_str(count, referenced) if count is not None else ""
|
||||
size_str = _human_size(size_bytes)
|
||||
row = f"{text:<{_LABEL_WIDTH}}{count_str:<{_COUNT_WIDTH}}{size_str:>10}"
|
||||
return _colorize(row, level) if level else row
|
||||
|
||||
|
||||
def _reason_line(reason: str, indent: int) -> str:
|
||||
return _colorize(" " * indent + "↳ " + _truncate(reason), "reason")
|
||||
|
||||
|
||||
def print_status(root: Path) -> None:
|
||||
raw_root = root / "raw"
|
||||
if not raw_root.is_dir():
|
||||
print(f"no raw/ tree found under {root}")
|
||||
return
|
||||
manifest_referenced = _manifest_referenced_files(root / "pools")
|
||||
gen_reasons, schema_reasons = _parse_versions(root / "VERSIONS.md")
|
||||
grand_total = 0
|
||||
grand_files = 0
|
||||
for kind_dir in sorted(p for p in raw_root.iterdir() if p.is_dir()):
|
||||
kind = kind_dir.name
|
||||
gens = sorted(
|
||||
@@ -118,10 +342,15 @@ def print_status(root: Path) -> None:
|
||||
for m in (GEN_RE.match(p.name) for p in kind_dir.iterdir() if p.is_dir())
|
||||
if m
|
||||
)
|
||||
print(f"kind={kind}")
|
||||
print(_colorize(f"{kind}/", "kind"))
|
||||
kind_total = 0
|
||||
kind_files = 0
|
||||
for gen in gens:
|
||||
gen_tag = f"gen{gen}"
|
||||
schema_dir = root / "processed" / kind / gen_tag
|
||||
raw_gen_dir = raw_root / kind / gen_tag
|
||||
raw_size = _du(raw_gen_dir)
|
||||
processed_gen_dir = root / "processed" / kind / gen_tag
|
||||
schema_dir = processed_gen_dir
|
||||
schemas = sorted(
|
||||
int(m.group(1))
|
||||
for m in (
|
||||
@@ -131,26 +360,98 @@ def print_status(root: Path) -> None:
|
||||
)
|
||||
if m
|
||||
)
|
||||
schema_str = ", ".join(f"schema{s}" for s in schemas) or "(none)"
|
||||
print(f" {gen_tag}: {schema_str}")
|
||||
schema_sizes = {s: _du(schema_dir / f"schema{s}") for s in schemas}
|
||||
schema_counts = {
|
||||
s: _referenced_parquet_count(
|
||||
schema_dir / f"schema{s}", manifest_referenced
|
||||
)
|
||||
for s in schemas
|
||||
}
|
||||
processed_size = sum(schema_sizes.values())
|
||||
processed_files = sum(c[0] for c in schema_counts.values())
|
||||
processed_referenced = sum(c[1] for c in schema_counts.values())
|
||||
raw_files, raw_referenced = _referenced_root_count(
|
||||
raw_gen_dir, processed_gen_dir
|
||||
)
|
||||
gen_total = raw_size + processed_size
|
||||
gen_files = raw_files + processed_files
|
||||
kind_total += gen_total
|
||||
kind_files += gen_files
|
||||
|
||||
print(_row(gen_tag, gen_total, indent=1, level="gen", count=gen_files))
|
||||
gen_reason = gen_reasons.get((kind, gen_tag))
|
||||
if gen_reason:
|
||||
print(_reason_line(gen_reason, indent=2))
|
||||
print(
|
||||
_row(
|
||||
"raw",
|
||||
raw_size,
|
||||
indent=2,
|
||||
level="bucket",
|
||||
count=raw_files,
|
||||
referenced=raw_referenced,
|
||||
)
|
||||
)
|
||||
print(
|
||||
_row(
|
||||
"processed",
|
||||
processed_size,
|
||||
indent=2,
|
||||
level="bucket",
|
||||
count=processed_files,
|
||||
referenced=processed_referenced,
|
||||
)
|
||||
)
|
||||
if schemas:
|
||||
for s in schemas:
|
||||
s_total, s_referenced = schema_counts[s]
|
||||
print(
|
||||
_row(
|
||||
f"schema{s}",
|
||||
schema_sizes[s],
|
||||
indent=3,
|
||||
level="schema",
|
||||
count=s_total,
|
||||
referenced=s_referenced,
|
||||
)
|
||||
)
|
||||
schema_reason = schema_reasons.get((kind, gen_tag, f"schema{s}"))
|
||||
if schema_reason:
|
||||
print(_reason_line(schema_reason, indent=4))
|
||||
else:
|
||||
print(_colorize(" (none)", "schema"))
|
||||
print(
|
||||
_row(f"{kind} total", kind_total, indent=1, level="gen", count=kind_files)
|
||||
)
|
||||
print()
|
||||
grand_total += kind_total
|
||||
grand_files += kind_files
|
||||
|
||||
derived_dir = root / "derived"
|
||||
pools_dir = root / "pools"
|
||||
derived_size = _du(derived_dir)
|
||||
pools_size = _du(pools_dir)
|
||||
derived_files = _count_files(derived_dir)
|
||||
pools_files = _count_files(pools_dir)
|
||||
grand_total += derived_size + pools_size
|
||||
grand_files += derived_files + pools_files
|
||||
print(_row("derived/", derived_size, level="root", count=derived_files))
|
||||
print(_row("pools/", pools_size, level="root", count=pools_files))
|
||||
print("-" * _ROW_WIDTH)
|
||||
print(_row("grand total", grand_total, level="root", count=grand_files))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# update-manifest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _find_schema_idx(abs_path: Path) -> int | None:
|
||||
"""Return the index of the first schemaN component in abs_path.parts, or None."""
|
||||
for i, part in enumerate(abs_path.parts):
|
||||
if SCHEMA_RE.match(part):
|
||||
return i
|
||||
return None
|
||||
|
||||
|
||||
def plan_update_manifest(
|
||||
manifest_path: Path, target_schema: str | None
|
||||
manifest_path: Path,
|
||||
target_schema: str | None,
|
||||
target_gen: str | None = None,
|
||||
) -> tuple[list[tuple[str, str | None]], list[Path]]:
|
||||
"""Parse a manifest and plan schema replacements for each data line.
|
||||
"""Parse a manifest and plan gen/schema replacements for each data line.
|
||||
|
||||
Returns:
|
||||
lines: list of (original_line, new_relative_path_or_None)
|
||||
@@ -174,42 +475,59 @@ def plan_update_manifest(
|
||||
continue
|
||||
|
||||
old_abs = (manifest_dir / stripped).resolve()
|
||||
schema_idx = _find_schema_idx(old_abs)
|
||||
if schema_idx is None:
|
||||
result.append((raw, None))
|
||||
continue
|
||||
|
||||
parts = list(old_abs.parts)
|
||||
old_schema = parts[schema_idx]
|
||||
gen_dir = Path(*parts[:schema_idx])
|
||||
changed = False
|
||||
|
||||
if target_schema is not None:
|
||||
new_schema = target_schema
|
||||
else:
|
||||
if gen_dir not in schema_cache:
|
||||
n = _max_index(gen_dir, SCHEMA_RE)
|
||||
if n == 0:
|
||||
raise SystemExit(f"error: no schema dirs found under {gen_dir}")
|
||||
schema_cache[gen_dir] = f"schema{n}"
|
||||
new_schema = schema_cache[gen_dir]
|
||||
# Apply gen replacement first (shifts subsequent indices).
|
||||
if target_gen is not None:
|
||||
gen_idx = _find_component_idx(Path(*parts), GEN_RE)
|
||||
if gen_idx is not None and parts[gen_idx] != target_gen:
|
||||
parts[gen_idx] = target_gen
|
||||
changed = True
|
||||
|
||||
if new_schema == old_schema:
|
||||
result.append((raw, None))
|
||||
continue
|
||||
schema_idx = _find_component_idx(Path(*parts), SCHEMA_RE)
|
||||
if schema_idx is not None:
|
||||
old_schema = parts[schema_idx]
|
||||
gen_dir = Path(*parts[:schema_idx])
|
||||
|
||||
parts[schema_idx] = new_schema
|
||||
if target_schema is not None:
|
||||
new_schema = target_schema
|
||||
else:
|
||||
if gen_dir not in schema_cache:
|
||||
n = _max_index(gen_dir, SCHEMA_RE)
|
||||
if n == 0:
|
||||
raise SystemExit(f"error: no schema dirs found under {gen_dir}")
|
||||
schema_cache[gen_dir] = f"schema{n}"
|
||||
new_schema = schema_cache[gen_dir]
|
||||
|
||||
if new_schema != old_schema:
|
||||
parts[schema_idx] = new_schema
|
||||
changed = True
|
||||
|
||||
# Check existence for every data line, not just ones whose gen/schema
|
||||
# actually changed — an already-correct-looking line can still point
|
||||
# at a file that was deleted or moved out-of-band.
|
||||
new_abs = Path(*parts)
|
||||
if not new_abs.exists():
|
||||
missing.append(new_abs)
|
||||
|
||||
if not changed:
|
||||
result.append((raw, None))
|
||||
continue
|
||||
|
||||
new_rel = os.path.relpath(new_abs, start=manifest_dir)
|
||||
result.append((raw, new_rel))
|
||||
|
||||
return result, missing
|
||||
|
||||
|
||||
def apply_update_manifest(manifest_path: Path, lines: list[tuple[str, str | None]]) -> None:
|
||||
out = [replacement if replacement is not None else original for original, replacement in lines]
|
||||
def apply_update_manifest(
|
||||
manifest_path: Path, lines: list[tuple[str, str | None]]
|
||||
) -> None:
|
||||
out = [
|
||||
replacement if replacement is not None else original
|
||||
for original, replacement in lines
|
||||
]
|
||||
manifest_path.write_text("\n".join(out) + "\n")
|
||||
|
||||
|
||||
@@ -217,6 +535,7 @@ def apply_update_manifest(manifest_path: Path, lines: list[tuple[str, str | None
|
||||
# create-manifest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_manifest_files(manifest_path: Path) -> list[Path]:
|
||||
"""Read a manifest and return its entries as resolved absolute paths."""
|
||||
files = []
|
||||
@@ -287,183 +606,167 @@ def apply_create_manifest(output_path: Path, lines: list[str]) -> None:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# CLI entry points (called from scripts/dwarf.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--root",
|
||||
default="/ceph/lbogner/geant_steps",
|
||||
help="Dataset root for bump-gen/bump-schema/status (default: /ceph/lbogner/geant_steps)",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
kind_help = "steps | hits | ... (default: steps)"
|
||||
def run_status(root: str) -> None:
|
||||
root_path = Path(root)
|
||||
if not root_path.is_dir():
|
||||
raise SystemExit(f"error: {root_path} is not a directory")
|
||||
print_status(root_path)
|
||||
|
||||
p_gen = sub.add_parser("bump-gen", help="Cut a new raw generation")
|
||||
p_gen.add_argument("--kind", default="steps", help=kind_help)
|
||||
p_gen.add_argument("--reason", required=True, help="Why this gen exists")
|
||||
p_gen.add_argument("--by", default=None, help="Attribution (default: git user.name)")
|
||||
p_gen.add_argument("--date", default=None, help="Override date (default: today, ISO)")
|
||||
p_gen.add_argument("--execute", action="store_true", help="Apply (default: dry run)")
|
||||
|
||||
p_schema = sub.add_parser("bump-schema", help="Cut a new schema within a gen")
|
||||
p_schema.add_argument("--kind", default="steps", help=kind_help)
|
||||
p_schema.add_argument("--gen", required=True, help="Existing gen tag, e.g. gen1")
|
||||
p_schema.add_argument("--reason", required=True, help="Why this schema exists")
|
||||
p_schema.add_argument("--by", default=None, help="Attribution (default: git user.name)")
|
||||
p_schema.add_argument("--date", default=None, help="Override date (default: today, ISO)")
|
||||
p_schema.add_argument("--execute", action="store_true", help="Apply (default: dry run)")
|
||||
def _run_bump(
|
||||
kind: str,
|
||||
reason: str,
|
||||
by: str | None,
|
||||
date: str | None,
|
||||
execute: bool,
|
||||
root: str,
|
||||
gen: str | None,
|
||||
to: str | None,
|
||||
) -> None:
|
||||
root_path = Path(root)
|
||||
if not root_path.is_dir():
|
||||
raise SystemExit(f"error: {root_path} is not a directory")
|
||||
|
||||
p_update = sub.add_parser(
|
||||
"update-manifest",
|
||||
help="Repoint manifest(s) to a new schema, verifying all target files exist",
|
||||
)
|
||||
p_update.add_argument(
|
||||
"manifests", nargs="+", metavar="MANIFEST", help="One or more .manifest files to update"
|
||||
)
|
||||
p_update.add_argument(
|
||||
"--schema",
|
||||
default=None,
|
||||
metavar="schemaN",
|
||||
help="Target schema tag (default: highest schema found in the same gen dir)",
|
||||
)
|
||||
p_update.add_argument("--execute", action="store_true", help="Write updated manifests (default: dry run)")
|
||||
date = date or dt.date.today().isoformat()
|
||||
by = by if by is not None else _git_user_name()
|
||||
if gen is None:
|
||||
new_dirs, log_line = plan_bump_gen(root_path, kind, reason, by, date, to)
|
||||
else:
|
||||
new_dirs, log_line = plan_bump_schema(
|
||||
root_path, kind, gen, reason, by, date, to
|
||||
)
|
||||
|
||||
p_create = sub.add_parser(
|
||||
"create-manifest",
|
||||
help="Create a new manifest from a list of parquet files",
|
||||
)
|
||||
dest_group = p_create.add_mutually_exclusive_group(required=True)
|
||||
dest_group.add_argument(
|
||||
"--output", "-o", metavar="PATH", help="Explicit path for the new .manifest file"
|
||||
)
|
||||
dest_group.add_argument(
|
||||
"--pool", metavar="DETECTOR",
|
||||
help="Detector name; combined with --type and --root to form <root>/pools/<detector>/<type>.manifest",
|
||||
)
|
||||
p_create.add_argument(
|
||||
"--type", choices=["full", "holdout", "dev"],
|
||||
help="Pool type — full, holdout, or dev (required with --pool)",
|
||||
)
|
||||
p_create.add_argument(
|
||||
"files", nargs="+", metavar="FILE", help="Parquet files to include"
|
||||
)
|
||||
p_create.add_argument("--execute", action="store_true", help="Write the manifest (default: dry run)")
|
||||
print(f"=== {'EXECUTING' if execute else 'DRY RUN'} ===")
|
||||
print("new directories:")
|
||||
for d in new_dirs:
|
||||
print(f" {d}")
|
||||
print("VERSIONS.md entry:")
|
||||
print(f" {log_line}")
|
||||
|
||||
sub.add_parser("status", help="List existing gens/schemas per kind")
|
||||
if not execute:
|
||||
print("\nDry run only — pass --execute to apply.")
|
||||
return
|
||||
apply_bump(root_path, new_dirs, log_line)
|
||||
print("\nDone.")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Commands that need --root
|
||||
if args.command in ("bump-gen", "bump-schema", "status"):
|
||||
root = Path(args.root)
|
||||
if not root.is_dir():
|
||||
parser.error(f"{root} is not a directory")
|
||||
def run_bump_gen(
|
||||
kind: str,
|
||||
reason: str,
|
||||
by: str | None,
|
||||
date: str | None,
|
||||
execute: bool,
|
||||
root: str,
|
||||
to: str | None = None,
|
||||
) -> None:
|
||||
_run_bump(kind, reason, by, date, execute, root, gen=None, to=to)
|
||||
|
||||
if args.command == "status":
|
||||
print_status(root)
|
||||
|
||||
def run_bump_schema(
|
||||
kind: str,
|
||||
gen: str,
|
||||
reason: str,
|
||||
by: str | None,
|
||||
date: str | None,
|
||||
execute: bool,
|
||||
root: str,
|
||||
to: str | None = None,
|
||||
) -> None:
|
||||
_run_bump(kind, reason, by, date, execute, root, gen=gen, to=to)
|
||||
|
||||
|
||||
def run_update_manifest(
|
||||
manifests: list[str],
|
||||
schema: str | None,
|
||||
execute: bool,
|
||||
gen: str | None = None,
|
||||
) -> None:
|
||||
if schema and not SCHEMA_RE.match(schema):
|
||||
raise SystemExit(f"error: --schema must look like 'schemaN', got {schema!r}")
|
||||
if gen and not GEN_RE.match(gen):
|
||||
raise SystemExit(f"error: --gen must look like 'genN', got {gen!r}")
|
||||
|
||||
all_plans: list[tuple[Path, list[tuple[str, str | None]]]] = []
|
||||
all_missing: list[Path] = []
|
||||
|
||||
for raw in manifests:
|
||||
mp = Path(raw)
|
||||
plan, missing = plan_update_manifest(mp, schema, gen)
|
||||
all_plans.append((mp.resolve(), plan))
|
||||
all_missing.extend(missing)
|
||||
|
||||
print(f"=== {'EXECUTING' if execute else 'DRY RUN'} ===")
|
||||
for mp, plan in all_plans:
|
||||
changes = [(old, new) for old, new in plan if new is not None]
|
||||
print(f"\n{mp} ({len(changes)} path(s) to update)")
|
||||
for old, new in changes:
|
||||
print(f" - {old.strip()}")
|
||||
print(f" + {new}")
|
||||
|
||||
if all_missing:
|
||||
print(f"\nMISSING ({len(all_missing)} file(s) — target paths do not exist):")
|
||||
for p in all_missing:
|
||||
print(f" {p}")
|
||||
if execute:
|
||||
raise SystemExit("error: refusing to write manifests with missing targets")
|
||||
|
||||
if not execute:
|
||||
print("\nDry run only — pass --execute to apply.")
|
||||
return
|
||||
|
||||
if args.command in ("bump-gen", "bump-schema"):
|
||||
date = args.date or dt.date.today().isoformat()
|
||||
by = args.by if args.by is not None else _git_user_name()
|
||||
if args.command == "bump-gen":
|
||||
new_dirs, log_line = plan_bump_gen(root, args.kind, args.reason, by, date)
|
||||
else:
|
||||
new_dirs, log_line = plan_bump_schema(root, args.kind, args.gen, args.reason, by, date)
|
||||
for mp, plan in all_plans:
|
||||
apply_update_manifest(mp, plan)
|
||||
print("\nDone.")
|
||||
|
||||
print(f"=== {'EXECUTING' if args.execute else 'DRY RUN'} ===")
|
||||
print("new directories:")
|
||||
for d in new_dirs:
|
||||
print(f" {d}")
|
||||
print("VERSIONS.md entry:")
|
||||
print(f" {log_line}")
|
||||
|
||||
if not args.execute:
|
||||
print("\nDry run only — pass --execute to apply.")
|
||||
return
|
||||
apply_bump(root, new_dirs, log_line)
|
||||
print("\nDone.")
|
||||
def run_create_manifest(
|
||||
files: list[str],
|
||||
execute: bool,
|
||||
output: str | None = None,
|
||||
pool: str | None = None,
|
||||
type_: str | None = None,
|
||||
root: str = "/ceph/lbogner/geant_steps",
|
||||
) -> None:
|
||||
if (output is None) == (pool is None):
|
||||
raise SystemExit("error: exactly one of --output or --pool is required")
|
||||
if pool is not None and type_ is None:
|
||||
raise SystemExit("error: --type is required when --pool is given")
|
||||
|
||||
if pool is not None:
|
||||
output_path = Path(root) / "pools" / pool / f"{type_}.manifest"
|
||||
else:
|
||||
assert output is not None # guaranteed by the exclusivity check above
|
||||
output_path = Path(output)
|
||||
|
||||
parquet_files = [Path(f) for f in files]
|
||||
lines, missing, resolved = plan_create_manifest(output_path, parquet_files)
|
||||
overlaps = check_holdout_overlap(output_path, resolved)
|
||||
|
||||
print(f"=== {'EXECUTING' if execute else 'DRY RUN'} ===")
|
||||
print(f"manifest: {output_path.resolve()}")
|
||||
for line in lines:
|
||||
print(f" {line}")
|
||||
|
||||
if missing:
|
||||
print(f"\nMISSING ({len(missing)} file(s) do not exist):")
|
||||
for p in missing:
|
||||
print(f" {p}")
|
||||
|
||||
if overlaps:
|
||||
print(f"\nHOLDOUT OVERLAP ({len(overlaps)} file(s) appear in other manifests):")
|
||||
for name, f in overlaps:
|
||||
print(f" {f} (also in {name})")
|
||||
|
||||
if (missing or overlaps) and execute:
|
||||
raise SystemExit("error: refusing to write manifest (see above)")
|
||||
|
||||
if not execute:
|
||||
print("\nDry run only — pass --execute to apply.")
|
||||
return
|
||||
|
||||
if args.command == "update-manifest":
|
||||
if args.schema and not SCHEMA_RE.match(args.schema):
|
||||
parser.error(f"--schema must look like 'schemaN', got {args.schema!r}")
|
||||
|
||||
all_plans: list[tuple[Path, list[tuple[str, str | None]]]] = []
|
||||
all_missing: list[Path] = []
|
||||
|
||||
for raw in args.manifests:
|
||||
mp = Path(raw)
|
||||
plan, missing = plan_update_manifest(mp, args.schema)
|
||||
all_plans.append((mp.resolve(), plan))
|
||||
all_missing.extend(missing)
|
||||
|
||||
print(f"=== {'EXECUTING' if args.execute else 'DRY RUN'} ===")
|
||||
for mp, plan in all_plans:
|
||||
changes = [(old, new) for old, new in plan if new is not None]
|
||||
print(f"\n{mp} ({len(changes)} path(s) to update)")
|
||||
for old, new in changes:
|
||||
print(f" - {old.strip()}")
|
||||
print(f" + {new}")
|
||||
|
||||
if all_missing:
|
||||
print(f"\nMISSING ({len(all_missing)} file(s) — target paths do not exist):")
|
||||
for p in all_missing:
|
||||
print(f" {p}")
|
||||
if args.execute:
|
||||
raise SystemExit("error: refusing to write manifests with missing targets")
|
||||
|
||||
if not args.execute:
|
||||
print("\nDry run only — pass --execute to apply.")
|
||||
return
|
||||
|
||||
for mp, plan in all_plans:
|
||||
apply_update_manifest(mp, plan)
|
||||
print("\nDone.")
|
||||
return
|
||||
|
||||
if args.command == "create-manifest":
|
||||
if args.pool is not None and args.type is None:
|
||||
parser.error("--type is required when --pool is given")
|
||||
|
||||
if args.pool is not None:
|
||||
root = Path(args.root)
|
||||
output_path = root / "pools" / args.pool / f"{args.type}.manifest"
|
||||
else:
|
||||
output_path = Path(args.output)
|
||||
|
||||
parquet_files = [Path(f) for f in args.files]
|
||||
lines, missing, resolved = plan_create_manifest(output_path, parquet_files)
|
||||
overlaps = check_holdout_overlap(output_path, resolved)
|
||||
|
||||
print(f"=== {'EXECUTING' if args.execute else 'DRY RUN'} ===")
|
||||
print(f"manifest: {output_path.resolve()}")
|
||||
for line in lines:
|
||||
print(f" {line}")
|
||||
|
||||
if missing:
|
||||
print(f"\nMISSING ({len(missing)} file(s) do not exist):")
|
||||
for p in missing:
|
||||
print(f" {p}")
|
||||
|
||||
if overlaps:
|
||||
print(f"\nHOLDOUT OVERLAP ({len(overlaps)} file(s) appear in other manifests):")
|
||||
for name, f in overlaps:
|
||||
print(f" {f} (also in {name})")
|
||||
|
||||
if (missing or overlaps) and args.execute:
|
||||
raise SystemExit("error: refusing to write manifest (see above)")
|
||||
|
||||
if not args.execute:
|
||||
print("\nDry run only — pass --execute to apply.")
|
||||
return
|
||||
|
||||
apply_create_manifest(output_path, lines)
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
apply_create_manifest(output_path, lines)
|
||||
print("\nDone.")
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate new ROOT shards by running a minicalosim executable (e.g.
|
||||
run_pbwo4, run_sampling) and filing the output into the dataset's raw/ tree:
|
||||
|
||||
@@ -15,19 +14,11 @@ appear there, and moves it to the next free shard index for that detector
|
||||
(existing shards are never overwritten).
|
||||
|
||||
--gen must already exist under raw/<kind>/ — create one first with
|
||||
bump_dataset_version.py bump-gen.
|
||||
`dwarf bump-gen`.
|
||||
|
||||
Usage:
|
||||
create_root_files.py --executable build/run_pbwo4 --detector pbwo4 \\
|
||||
--gen gen1 --num-files 4 --events-per-file 10000 --execute
|
||||
|
||||
create_root_files.py --executable build/run_sampling \\
|
||||
--detector sampling_pb_scint:pb_scint \\
|
||||
--detector sampling_fe_scint:fe_scint \\
|
||||
--gen gen1 --num-files 4 --events-per-file 10000 --jobs 8 --execute
|
||||
See `uv run dwarf make-root --help` for the CLI.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@@ -70,7 +61,9 @@ def parse_detector_spec(spec: str) -> tuple[str, str | None]:
|
||||
if ":" in spec:
|
||||
label, config = spec.split(":", 1)
|
||||
if not label or not config:
|
||||
raise PlanError(f"invalid --detector spec {spec!r}: expected NAME or NAME:CONFIG")
|
||||
raise PlanError(
|
||||
f"invalid --detector spec {spec!r}: expected NAME or NAME:CONFIG"
|
||||
)
|
||||
return label, config
|
||||
return spec, None
|
||||
|
||||
@@ -120,7 +113,10 @@ def run_job(
|
||||
gen: str,
|
||||
tmp_root: Path,
|
||||
) -> JobResult:
|
||||
workdir = tmp_root / f"{kind}-{gen}-{job.detector}-{job.shard_index:03d}-{uuid.uuid4().hex[:8]}"
|
||||
workdir = (
|
||||
tmp_root
|
||||
/ f"{kind}-{gen}-{job.detector}-{job.shard_index:03d}-{uuid.uuid4().hex[:8]}"
|
||||
)
|
||||
workdir.mkdir(parents=True)
|
||||
|
||||
cmd = [str(executable)]
|
||||
@@ -132,25 +128,42 @@ def run_job(
|
||||
|
||||
if result.returncode != 0:
|
||||
return JobResult(
|
||||
job, False, None,
|
||||
job,
|
||||
False,
|
||||
None,
|
||||
f"executable exited {result.returncode}",
|
||||
result.stdout, result.stderr,
|
||||
result.stdout,
|
||||
result.stderr,
|
||||
)
|
||||
|
||||
produced = sorted(workdir.glob("*.root"))
|
||||
if len(produced) != 1:
|
||||
return JobResult(
|
||||
job, False, None,
|
||||
job,
|
||||
False,
|
||||
None,
|
||||
f"expected exactly one .root output in {workdir}, found {len(produced)}: "
|
||||
f"{[p.name for p in produced]}",
|
||||
result.stdout, result.stderr,
|
||||
result.stdout,
|
||||
result.stderr,
|
||||
)
|
||||
|
||||
dest = dataset_root / "raw" / kind / gen / job.detector / f"shard-{job.shard_index:03d}.root"
|
||||
dest = (
|
||||
dataset_root
|
||||
/ "raw"
|
||||
/ kind
|
||||
/ gen
|
||||
/ job.detector
|
||||
/ f"shard-{job.shard_index:03d}.root"
|
||||
)
|
||||
if dest.exists():
|
||||
return JobResult(
|
||||
job, False, None, f"refusing to overwrite existing {dest}",
|
||||
result.stdout, result.stderr,
|
||||
job,
|
||||
False,
|
||||
None,
|
||||
f"refusing to overwrite existing {dest}",
|
||||
result.stdout,
|
||||
result.stderr,
|
||||
)
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -175,7 +188,14 @@ def run_all(
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {
|
||||
pool.submit(
|
||||
run_job, job, executable, events_per_file, dataset_root, kind, gen, tmp_root
|
||||
run_job,
|
||||
job,
|
||||
executable,
|
||||
events_per_file,
|
||||
dataset_root,
|
||||
kind,
|
||||
gen,
|
||||
tmp_root,
|
||||
): job
|
||||
for job in jobs
|
||||
}
|
||||
@@ -192,78 +212,65 @@ def run_all(
|
||||
return results
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--executable", required=True, type=Path, help="Built minicalosim run_* executable"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--detector",
|
||||
action="append",
|
||||
required=True,
|
||||
metavar="NAME[:CONFIG]",
|
||||
help="Dataset detector label, optionally with ':CONFIG' to pass as the "
|
||||
"executable's config-name argument (e.g. sampling_pb_scint:pb_scint). "
|
||||
"Omit ':CONFIG' for executables that take no config selector (e.g. run_pbwo4). "
|
||||
"Repeatable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num-files", type=int, required=True, help="New shards to create per detector"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--events-per-file", type=int, required=True, help="nEvents passed to the executable"
|
||||
)
|
||||
parser.add_argument("--kind", default="steps", help="steps | hits | ... (default: steps)")
|
||||
parser.add_argument("--gen", required=True, help="Existing gen tag under raw/<kind>/, e.g. gen1")
|
||||
parser.add_argument(
|
||||
"--dataset-root",
|
||||
default="/ceph/lbogner/geant_steps",
|
||||
help="Dataset root (default: /ceph/lbogner/geant_steps)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-j", "--jobs", type=int, default=4, help="Parallel simulation runs (default: 4)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--execute", action="store_true", help="Actually run jobs (default: dry run / print plan)"
|
||||
)
|
||||
return parser
|
||||
def run_make_root(
|
||||
executable: Path,
|
||||
detector: list[str],
|
||||
num_files: int,
|
||||
events_per_file: int,
|
||||
kind: str,
|
||||
gen: str,
|
||||
dataset_root: str,
|
||||
jobs: int,
|
||||
execute: bool,
|
||||
) -> None:
|
||||
if jobs < 1:
|
||||
raise SystemExit("error: --jobs must be >= 1")
|
||||
if num_files < 1:
|
||||
raise SystemExit("error: --num-files must be >= 1")
|
||||
if events_per_file < 1:
|
||||
raise SystemExit("error: --events-per-file must be >= 1")
|
||||
if not executable.is_file() or not os.access(executable, os.X_OK):
|
||||
raise SystemExit(f"error: {executable} is not an executable file")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.jobs < 1:
|
||||
parser.error("--jobs must be >= 1")
|
||||
if args.num_files < 1:
|
||||
parser.error("--num-files must be >= 1")
|
||||
if args.events_per_file < 1:
|
||||
parser.error("--events-per-file must be >= 1")
|
||||
if not args.executable.is_file() or not os.access(args.executable, os.X_OK):
|
||||
parser.error(f"{args.executable} is not an executable file")
|
||||
|
||||
dataset_root = Path(args.dataset_root)
|
||||
dataset_root_path = Path(dataset_root)
|
||||
try:
|
||||
jobs = plan_jobs(args.detector, args.num_files, dataset_root, args.kind, args.gen)
|
||||
planned_jobs = plan_jobs(detector, num_files, dataset_root_path, kind, gen)
|
||||
except PlanError as exc:
|
||||
parser.error(str(exc))
|
||||
raise SystemExit(f"error: {exc}")
|
||||
|
||||
print(f"=== {'EXECUTING' if args.execute else 'DRY RUN'} ===")
|
||||
print(f"executable: {args.executable}")
|
||||
for job in jobs:
|
||||
cmd = [str(args.executable)] + ([job.config] if job.config else []) + [str(args.events_per_file)]
|
||||
dest = dataset_root / "raw" / args.kind / args.gen / job.detector / f"shard-{job.shard_index:03d}.root"
|
||||
print(f"=== {'EXECUTING' if execute else 'DRY RUN'} ===")
|
||||
print(f"executable: {executable}")
|
||||
for job in planned_jobs:
|
||||
cmd = (
|
||||
[str(executable)]
|
||||
+ ([job.config] if job.config else [])
|
||||
+ [str(events_per_file)]
|
||||
)
|
||||
dest = (
|
||||
dataset_root_path
|
||||
/ "raw"
|
||||
/ kind
|
||||
/ gen
|
||||
/ job.detector
|
||||
/ f"shard-{job.shard_index:03d}.root"
|
||||
)
|
||||
print(f" {' '.join(cmd)} -> {dest}")
|
||||
|
||||
if not args.execute:
|
||||
if not execute:
|
||||
print("\nDry run only — pass --execute to apply.")
|
||||
return
|
||||
|
||||
tmp_root = dataset_root / ".sim-tmp"
|
||||
tmp_root = dataset_root_path / ".sim-tmp"
|
||||
tmp_root.mkdir(parents=True, exist_ok=True)
|
||||
results = run_all(
|
||||
jobs, args.executable, args.events_per_file, dataset_root, args.kind, args.gen,
|
||||
max_workers=args.jobs, tmp_root=tmp_root,
|
||||
planned_jobs,
|
||||
executable,
|
||||
events_per_file,
|
||||
dataset_root_path,
|
||||
kind,
|
||||
gen,
|
||||
max_workers=jobs,
|
||||
tmp_root=tmp_root,
|
||||
)
|
||||
if tmp_root.is_dir() and not any(tmp_root.iterdir()):
|
||||
tmp_root.rmdir()
|
||||
@@ -272,11 +279,10 @@ def main() -> None:
|
||||
if failures:
|
||||
print(f"\n{len(failures)} of {len(results)} job(s) failed:", file=sys.stderr)
|
||||
for r in failures:
|
||||
print(f" {r.job.detector} shard-{r.job.shard_index:03d}: {r.message}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(
|
||||
f" {r.job.detector} shard-{r.job.shard_index:03d}: {r.message}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
print(f"\nAll {len(results)} job(s) completed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
"""dwarf — little helper to `giant`: dataset/tooling CLI for the geant_steps pipeline.
|
||||
|
||||
Unifies the standalone scripts/*.py conversion, migration, versioning, and
|
||||
simulation-fanout tools into one Typer app so there's a single command name
|
||||
(and `--help`) to remember instead of five differently-hyphenated ones.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import typer
|
||||
from typing_extensions import Annotated
|
||||
|
||||
from scripts.bump_dataset_version import (
|
||||
run_bump_gen,
|
||||
run_bump_schema,
|
||||
run_create_manifest,
|
||||
run_status,
|
||||
run_update_manifest,
|
||||
)
|
||||
from scripts.create_root_files import run_make_root
|
||||
from scripts.hparam_scan import DATA_DEFAULT, SCAN_DIR_DEFAULT, run_hparam_scan
|
||||
from scripts.migrate_geant_steps import run_migration
|
||||
from scripts.steps_to_parquet import convert_steps_to_parquet
|
||||
from scripts.steps_to_parquet_parallel import run_parallel_job
|
||||
|
||||
app = typer.Typer(no_args_is_help=True)
|
||||
|
||||
_DATASET_ROOT_DEFAULT = Path("/ceph/lbogner/geant_steps")
|
||||
|
||||
|
||||
@app.callback()
|
||||
def _main() -> None:
|
||||
"""dwarf — dataset/tooling CLI (ROOT<->parquet conversion, dataset versioning, sim fanout)."""
|
||||
|
||||
|
||||
class Compression(str, Enum):
|
||||
snappy = "snappy"
|
||||
lz4 = "lz4"
|
||||
zstd = "zstd"
|
||||
gzip = "gzip"
|
||||
none = "none"
|
||||
|
||||
|
||||
class PoolType(str, Enum):
|
||||
full = "full"
|
||||
holdout = "holdout"
|
||||
dev = "dev"
|
||||
|
||||
|
||||
@app.command()
|
||||
def convert(
|
||||
root_files: Annotated[list[Path], typer.Argument(help="Input ROOT file(s)")],
|
||||
output: Annotated[
|
||||
Optional[Path],
|
||||
typer.Option(
|
||||
"--output",
|
||||
"-o",
|
||||
help="Output Parquet file (default: <input>.parquet). Only valid "
|
||||
"with a single input file and --jobs 1.",
|
||||
),
|
||||
] = None,
|
||||
batch_size: Annotated[
|
||||
str,
|
||||
typer.Option(
|
||||
"--batch-size",
|
||||
help="Uproot read batch size, e.g. '100 MB' or '500000' (rows)",
|
||||
),
|
||||
] = "100 MB",
|
||||
tree: Annotated[
|
||||
str, typer.Option("--tree", help="Tree name inside the ROOT file")
|
||||
] = "Steps",
|
||||
compression: Annotated[
|
||||
Compression, typer.Option("--compression", help="Parquet compression codec")
|
||||
] = Compression.snappy,
|
||||
jobs: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--jobs",
|
||||
"-j",
|
||||
help="Convert N files in parallel, resolving each destination from "
|
||||
"--dataset-root/--schema (default: 1, sequential, any file layout)",
|
||||
),
|
||||
] = 1,
|
||||
dataset_root: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--dataset-root",
|
||||
help="Dataset root containing raw/ and processed/ (only used with --jobs > 1)",
|
||||
),
|
||||
] = _DATASET_ROOT_DEFAULT,
|
||||
schema: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--schema",
|
||||
help="Schema tag to write parquets under, e.g. schema2 (only used "
|
||||
"with --jobs > 1; default: highest schemaN already under "
|
||||
"processed/<kind>/<gen>/)",
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Convert ROOT Steps tree(s) to Parquet."""
|
||||
if jobs < 1:
|
||||
typer.echo("error: --jobs must be >= 1", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
compression_value = (
|
||||
"uncompressed" if compression is Compression.none else compression.value
|
||||
)
|
||||
|
||||
if jobs == 1:
|
||||
if output is not None and len(root_files) > 1:
|
||||
typer.echo(
|
||||
"error: --output can only be used with a single input file", err=True
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
for root_file in root_files:
|
||||
convert_steps_to_parquet(
|
||||
root_file,
|
||||
output_path=output,
|
||||
batch_size=batch_size,
|
||||
tree_name=tree,
|
||||
compression=compression_value,
|
||||
)
|
||||
return
|
||||
|
||||
if output is not None:
|
||||
typer.echo(
|
||||
"error: --output cannot be combined with --jobs > 1 "
|
||||
"(destinations are derived from --dataset-root/--schema)",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
run_parallel_job(
|
||||
[str(f) for f in root_files],
|
||||
jobs=jobs,
|
||||
dataset_root=dataset_root,
|
||||
schema=schema,
|
||||
batch_size=batch_size,
|
||||
tree=tree,
|
||||
compression=compression_value,
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def migrate(
|
||||
root: Annotated[
|
||||
Path, typer.Argument(help="Dataset root to migrate in place")
|
||||
] = _DATASET_ROOT_DEFAULT,
|
||||
execute: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--execute",
|
||||
help="Actually move/copy files and write manifests (default: dry run)",
|
||||
),
|
||||
] = False,
|
||||
copy: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--copy",
|
||||
help="Copy instead of move, leaving the originals in place "
|
||||
"(e.g. if another process is still reading them)",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""One-time migration into the versioned raw/processed/pools/derived layout."""
|
||||
run_migration(str(root), execute=execute, copy=copy)
|
||||
|
||||
|
||||
@app.command("bump-gen")
|
||||
def bump_gen(
|
||||
reason: Annotated[str, typer.Option("--reason", help="Why this gen exists")],
|
||||
kind: Annotated[
|
||||
str, typer.Option("--kind", help="steps | hits | ... (default: steps)")
|
||||
] = "steps",
|
||||
by: Annotated[
|
||||
Optional[str], typer.Option("--by", help="Attribution (default: git user.name)")
|
||||
] = None,
|
||||
date: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--date", help="Override date (default: today, ISO)"),
|
||||
] = None,
|
||||
to: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--to",
|
||||
metavar="genN",
|
||||
help="Target gen tag (default: one past the current highest)",
|
||||
),
|
||||
] = None,
|
||||
execute: Annotated[
|
||||
bool, typer.Option("--execute", help="Apply (default: dry run)")
|
||||
] = False,
|
||||
root: Annotated[
|
||||
Path, typer.Option("--root", help="Dataset root")
|
||||
] = _DATASET_ROOT_DEFAULT,
|
||||
) -> None:
|
||||
"""Cut a new raw generation."""
|
||||
run_bump_gen(
|
||||
kind=kind,
|
||||
reason=reason,
|
||||
by=by,
|
||||
date=date,
|
||||
execute=execute,
|
||||
root=str(root),
|
||||
to=to,
|
||||
)
|
||||
|
||||
|
||||
@app.command("bump-schema")
|
||||
def bump_schema(
|
||||
gen: Annotated[str, typer.Option("--gen", help="Existing gen tag, e.g. gen1")],
|
||||
reason: Annotated[str, typer.Option("--reason", help="Why this schema exists")],
|
||||
kind: Annotated[
|
||||
str, typer.Option("--kind", help="steps | hits | ... (default: steps)")
|
||||
] = "steps",
|
||||
by: Annotated[
|
||||
Optional[str], typer.Option("--by", help="Attribution (default: git user.name)")
|
||||
] = None,
|
||||
date: Annotated[
|
||||
Optional[str],
|
||||
typer.Option("--date", help="Override date (default: today, ISO)"),
|
||||
] = None,
|
||||
to: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--to",
|
||||
metavar="schemaN",
|
||||
help="Target schema tag (default: one past the current highest)",
|
||||
),
|
||||
] = None,
|
||||
execute: Annotated[
|
||||
bool, typer.Option("--execute", help="Apply (default: dry run)")
|
||||
] = False,
|
||||
root: Annotated[
|
||||
Path, typer.Option("--root", help="Dataset root")
|
||||
] = _DATASET_ROOT_DEFAULT,
|
||||
) -> None:
|
||||
"""Cut a new schema within a gen."""
|
||||
run_bump_schema(
|
||||
kind=kind,
|
||||
gen=gen,
|
||||
reason=reason,
|
||||
by=by,
|
||||
date=date,
|
||||
execute=execute,
|
||||
root=str(root),
|
||||
to=to,
|
||||
)
|
||||
|
||||
|
||||
@app.command()
|
||||
def status(
|
||||
root: Annotated[
|
||||
Path, typer.Option("--root", help="Dataset root")
|
||||
] = _DATASET_ROOT_DEFAULT,
|
||||
) -> None:
|
||||
"""List existing gens/schemas per kind."""
|
||||
run_status(str(root))
|
||||
|
||||
|
||||
@app.command("update-manifest")
|
||||
def update_manifest(
|
||||
manifests: Annotated[
|
||||
list[Path], typer.Argument(help="One or more .manifest files to update")
|
||||
],
|
||||
schema: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--schema",
|
||||
metavar="schemaN",
|
||||
help="Target schema tag (default: highest schema found in the same gen dir)",
|
||||
),
|
||||
] = None,
|
||||
gen: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--gen", metavar="genN", help="Target gen tag (default: keep existing gen)"
|
||||
),
|
||||
] = None,
|
||||
execute: Annotated[
|
||||
bool,
|
||||
typer.Option("--execute", help="Write updated manifests (default: dry run)"),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Repoint manifest(s) to a new gen and/or schema, verifying all target files exist."""
|
||||
run_update_manifest(
|
||||
[str(m) for m in manifests], schema=schema, execute=execute, gen=gen
|
||||
)
|
||||
|
||||
|
||||
@app.command("create-manifest")
|
||||
def create_manifest(
|
||||
files: Annotated[list[Path], typer.Argument(help="Parquet files to include")],
|
||||
output: Annotated[
|
||||
Optional[Path],
|
||||
typer.Option("--output", "-o", help="Explicit path for the new .manifest file"),
|
||||
] = None,
|
||||
pool: Annotated[
|
||||
Optional[str],
|
||||
typer.Option(
|
||||
"--pool",
|
||||
metavar="DETECTOR",
|
||||
help="Detector name; combined with --type and --root to form "
|
||||
"<root>/pools/<detector>/<type>.manifest",
|
||||
),
|
||||
] = None,
|
||||
type_: Annotated[
|
||||
Optional[PoolType],
|
||||
typer.Option(
|
||||
"--type", help="Pool type — full, holdout, or dev (required with --pool)"
|
||||
),
|
||||
] = None,
|
||||
root: Annotated[
|
||||
Path, typer.Option("--root", help="Dataset root (used with --pool)")
|
||||
] = _DATASET_ROOT_DEFAULT,
|
||||
execute: Annotated[
|
||||
bool, typer.Option("--execute", help="Write the manifest (default: dry run)")
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Create a new manifest from a list of parquet files."""
|
||||
run_create_manifest(
|
||||
[str(f) for f in files],
|
||||
execute=execute,
|
||||
output=str(output) if output is not None else None,
|
||||
pool=pool,
|
||||
type_=type_.value if type_ is not None else None,
|
||||
root=str(root),
|
||||
)
|
||||
|
||||
|
||||
@app.command("make-root")
|
||||
def make_root(
|
||||
executable: Annotated[
|
||||
Path, typer.Option("--executable", help="Built minicalosim run_* executable")
|
||||
],
|
||||
detector: Annotated[
|
||||
list[str],
|
||||
typer.Option(
|
||||
"--detector",
|
||||
metavar="NAME[:CONFIG]",
|
||||
help="Dataset detector label, optionally with ':CONFIG' to pass as the "
|
||||
"executable's config-name argument (e.g. sampling_pb_scint:pb_scint). "
|
||||
"Omit ':CONFIG' for executables that take no config selector (e.g. run_pbwo4). "
|
||||
"Repeatable.",
|
||||
),
|
||||
],
|
||||
num_files: Annotated[
|
||||
int, typer.Option("--num-files", help="New shards to create per detector")
|
||||
],
|
||||
events_per_file: Annotated[
|
||||
int, typer.Option("--events-per-file", help="nEvents passed to the executable")
|
||||
],
|
||||
gen: Annotated[
|
||||
str, typer.Option("--gen", help="Existing gen tag under raw/<kind>/, e.g. gen1")
|
||||
],
|
||||
kind: Annotated[
|
||||
str, typer.Option("--kind", help="steps | hits | ... (default: steps)")
|
||||
] = "steps",
|
||||
dataset_root: Annotated[
|
||||
Path, typer.Option("--dataset-root", help="Dataset root")
|
||||
] = _DATASET_ROOT_DEFAULT,
|
||||
jobs: Annotated[
|
||||
int, typer.Option("--jobs", "-j", help="Parallel simulation runs (default: 4)")
|
||||
] = 4,
|
||||
execute: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--execute", help="Actually run jobs (default: dry run / print plan)"
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Generate new ROOT shards via a minicalosim executable."""
|
||||
run_make_root(
|
||||
executable=executable,
|
||||
detector=detector,
|
||||
num_files=num_files,
|
||||
events_per_file=events_per_file,
|
||||
kind=kind,
|
||||
gen=gen,
|
||||
dataset_root=str(dataset_root),
|
||||
jobs=jobs,
|
||||
execute=execute,
|
||||
)
|
||||
|
||||
|
||||
@app.command("hparam-scan")
|
||||
def hparam_scan(
|
||||
data: Annotated[str, typer.Option("--data")] = DATA_DEFAULT,
|
||||
scan_dir: Annotated[str, typer.Option("--scan-dir")] = SCAN_DIR_DEFAULT,
|
||||
seed: Annotated[int, typer.Option("--seed")] = 0,
|
||||
dry_run: Annotated[bool, typer.Option("--dry-run")] = False,
|
||||
) -> None:
|
||||
"""Grid-scan dropout x n_blocks x hidden_dim via sequential `giant train` runs."""
|
||||
run_hparam_scan(data=data, scan_dir=scan_dir, seed=seed, dry_run=dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
+14
-25
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hyperparameter scan over dropout x n_blocks x hidden_dim.
|
||||
|
||||
Runs `giant train` sequentially (this machine has a single GPU) for every
|
||||
@@ -6,13 +5,9 @@ combination, plus one extra run at the default architecture with a higher
|
||||
learning rate. Runs are shuffled so the parameter space gets coarse coverage
|
||||
early rather than exhausting one corner of the grid first.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/hparam_scan.py
|
||||
uv run python scripts/hparam_scan.py --dry-run
|
||||
uv run python scripts/hparam_scan.py --seed 1 --data /path/to/parquet
|
||||
See `uv run dwarf hparam-scan --help` for the CLI.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import itertools
|
||||
import os
|
||||
@@ -87,31 +82,29 @@ def append_summary(summary_path: Path, row: dict) -> None:
|
||||
writer.writerow(row)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--data", default=DATA_DEFAULT)
|
||||
parser.add_argument("--scan-dir", default=SCAN_DIR_DEFAULT)
|
||||
parser.add_argument("--seed", type=int, default=0)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
def run_hparam_scan(
|
||||
data: str = DATA_DEFAULT,
|
||||
scan_dir: str = SCAN_DIR_DEFAULT,
|
||||
seed: int = 0,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
runs = build_runs(seed)
|
||||
scan_dir_path = Path(scan_dir)
|
||||
|
||||
runs = build_runs(args.seed)
|
||||
scan_dir = Path(args.scan_dir)
|
||||
|
||||
if args.dry_run:
|
||||
if dry_run:
|
||||
for i, run in enumerate(runs, 1):
|
||||
print(f"[{i}/{len(runs)}] {run_name(run)}")
|
||||
return
|
||||
|
||||
scan_dir.mkdir(parents=True, exist_ok=True)
|
||||
summary_path = scan_dir / "scan_summary.csv"
|
||||
scan_dir_path.mkdir(parents=True, exist_ok=True)
|
||||
summary_path = scan_dir_path / "scan_summary.csv"
|
||||
|
||||
env = os.environ.copy()
|
||||
env["TQDM_DISABLE"] = "1"
|
||||
|
||||
for i, run in enumerate(runs, 1):
|
||||
name = run_name(run)
|
||||
out_dir = scan_dir / name
|
||||
out_dir = scan_dir_path / name
|
||||
metrics_path = out_dir / "metrics.csv"
|
||||
last_ckpt = out_dir / "last.pt"
|
||||
|
||||
@@ -124,7 +117,7 @@ def main() -> None:
|
||||
cmd = [
|
||||
"giant",
|
||||
"train",
|
||||
args.data,
|
||||
data,
|
||||
"--mode",
|
||||
"flow",
|
||||
"--epochs",
|
||||
@@ -186,7 +179,3 @@ def main() -> None:
|
||||
print(
|
||||
f"[{i}/{len(runs)}] {name} — no metrics.csv produced, check train.log"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-time migration of /ceph/lbogner/geant_steps into the versioned layout:
|
||||
|
||||
raw/<kind>/<gen>/<detector>/shard-NNN.root
|
||||
@@ -13,12 +12,13 @@ up pool membership from POOL_ASSIGNMENT — decoupling "where it sits today" fro
|
||||
"which pool it belongs to".
|
||||
|
||||
Defaults to a dry run (prints the planned moves and manifest contents). Pass
|
||||
--execute to actually move files and write manifests. Pass --copy as well to
|
||||
copy instead of move, leaving the original files in place — e.g. if another
|
||||
process is still reading them from their current location.
|
||||
execute=True to actually move files and write manifests, and copy=True to copy
|
||||
instead of move, leaving the original files in place — e.g. if another process
|
||||
is still reading them from their current location.
|
||||
|
||||
See `uv run dwarf migrate --help` for the CLI.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@@ -181,39 +181,18 @@ def plan_manifests(src_root: Path) -> dict[Path, list[str]]:
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"root",
|
||||
nargs="?",
|
||||
default="/ceph/lbogner/geant_steps",
|
||||
help="Dataset root to migrate in place (default: /ceph/lbogner/geant_steps)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--execute",
|
||||
action="store_true",
|
||||
help="Actually move files and write manifests (default: dry run / print plan only)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--copy",
|
||||
action="store_true",
|
||||
help="Copy instead of move, leaving the originals in place "
|
||||
"(e.g. if another process is still reading them). Implies the legacy "
|
||||
"train/ etc. directories are left as-is too, since they won't be empty.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
src_root = Path(args.root)
|
||||
def run_migration(root: str, execute: bool, copy: bool) -> None:
|
||||
src_root = Path(root)
|
||||
if not src_root.is_dir():
|
||||
parser.error(f"{src_root} is not a directory")
|
||||
raise SystemExit(f"error: {src_root} is not a directory")
|
||||
|
||||
moves, unrecognized = plan_moves(src_root)
|
||||
manifests = plan_manifests(src_root)
|
||||
|
||||
verb = "COPY" if args.copy else "MOVE"
|
||||
print(f"=== {'EXECUTING' if args.execute else 'DRY RUN'} ({verb}): {src_root} ===\n")
|
||||
verb = "COPY" if copy else "MOVE"
|
||||
print(f"=== {'EXECUTING' if execute else 'DRY RUN'} ({verb}): {src_root} ===\n")
|
||||
|
||||
print(f"-- {len(moves)} file {'copy' if args.copy else 'move'}(s) --")
|
||||
print(f"-- {len(moves)} file {'copy' if copy else 'move'}(s) --")
|
||||
for src, dst in moves:
|
||||
print(f" {src.relative_to(src_root)} -> {dst.relative_to(src_root)}")
|
||||
|
||||
@@ -228,11 +207,11 @@ def main() -> None:
|
||||
for path in unrecognized:
|
||||
print(f" {path.relative_to(src_root)}")
|
||||
|
||||
if not args.execute:
|
||||
if not execute:
|
||||
print("\nDry run only — pass --execute to apply.")
|
||||
return
|
||||
|
||||
transfer = shutil.copy2 if args.copy else shutil.move
|
||||
transfer = shutil.copy2 if copy else shutil.move
|
||||
for src, dst in moves:
|
||||
if dst.exists():
|
||||
raise FileExistsError(f"refusing to overwrite existing file: {dst}")
|
||||
@@ -257,7 +236,7 @@ def main() -> None:
|
||||
# empty since their contents were classified by filename, not location —
|
||||
# remove them, but only if a move actually emptied them. In --copy mode the
|
||||
# originals are still there by design, so leave these alone entirely.
|
||||
if not args.copy:
|
||||
if not copy:
|
||||
for stale_dir in ("train", "sampling_train/small", "sampling_train"):
|
||||
d = src_root / stale_dir
|
||||
if d.is_dir() and not any(d.iterdir()):
|
||||
@@ -265,7 +244,3 @@ def main() -> None:
|
||||
print(f"removed now-empty directory: {d.relative_to(src_root)}")
|
||||
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert the Steps tree from a ROOT file to Parquet.
|
||||
|
||||
Usage:
|
||||
uv run python steps_to_parquet.py input.root
|
||||
uv run python steps_to_parquet.py input.root -o output.parquet
|
||||
uv run python steps_to_parquet.py input.root --batch-size "200 MB" --tree Hits
|
||||
uv run python steps_to_parquet.py input1.root input2.root input3.root
|
||||
See `uv run dwarf convert --help` for the CLI.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
@@ -158,50 +152,3 @@ def convert_steps_to_parquet(
|
||||
df.write_parquet(output_path, compression=compression)
|
||||
print(f"done ({output_path.stat().st_size / 1e6:.1f} MB)")
|
||||
return output_path
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert a Steps (or any flat+jagged) tree in a ROOT file to Parquet."
|
||||
)
|
||||
parser.add_argument("root_files", nargs="+", help="Input ROOT file(s)")
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
help="Output Parquet file (default: <input>.parquet). "
|
||||
"Only valid with a single input file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
default="100 MB",
|
||||
help="Uproot read batch size (default: '100 MB'). E.g. '50 MB', '500000' (rows).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tree",
|
||||
default="Steps",
|
||||
help="Tree name inside the ROOT file (default: Steps)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--compression",
|
||||
default="snappy",
|
||||
choices=["snappy", "lz4", "zstd", "gzip", "none"],
|
||||
help="Parquet compression codec (default: snappy)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.output is not None and len(args.root_files) > 1:
|
||||
parser.error("--output can only be used with a single input file")
|
||||
|
||||
compression = "uncompressed" if args.compression == "none" else args.compression
|
||||
for root_file in args.root_files:
|
||||
convert_steps_to_parquet(
|
||||
root_file,
|
||||
output_path=args.output,
|
||||
batch_size=args.batch_size,
|
||||
tree_name=args.tree,
|
||||
compression=compression,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert many ROOT files to Parquet by fanning out to steps_to_parquet.py.
|
||||
"""Convert many ROOT files to Parquet by fanning out to `dwarf convert`.
|
||||
|
||||
steps_to_parquet.py itself converts a list of files one at a time; this wraps
|
||||
it to run up to --jobs conversions concurrently, each as its own subprocess
|
||||
(invoked with the same Python executable running this script, so it picks up
|
||||
A single `dwarf convert` call converts a list of files one at a time; this
|
||||
module runs up to --jobs conversions concurrently, each as its own `dwarf
|
||||
convert` subprocess (invoked via `python -m scripts.dwarf`, so it picks up
|
||||
the active venv/uv environment automatically).
|
||||
|
||||
Inputs must live under <dataset-root>/raw/<kind>/<gen>/<detector>/<file>.root
|
||||
(see scripts/migrate_geant_steps.py) — each is written to the matching
|
||||
processed/<kind>/<gen>/<schema>/<detector>/<file>.parquet, where <schema>
|
||||
defaults to the highest schemaN already under processed/<kind>/<gen>/ (pass
|
||||
--schema to pick a specific one, e.g. one just created by
|
||||
bump_dataset_version.py bump-schema). A file that doesn't fit that layout is
|
||||
rejected up front, before any conversion runs.
|
||||
--schema to pick a specific one, e.g. one just created by `dwarf bump-schema`).
|
||||
A file that doesn't fit that layout is rejected up front, before any
|
||||
conversion runs.
|
||||
|
||||
Usage:
|
||||
steps_to_parquet_parallel.py raw/steps/gen1/pbwo4/shard-000.root raw/steps/gen1/pbwo4/shard-001.root
|
||||
steps_to_parquet_parallel.py raw/steps/gen1/pbwo4/*.root --jobs 8
|
||||
steps_to_parquet_parallel.py raw/steps/gen1/pbwo4/*.root --schema schema2
|
||||
See `uv run dwarf convert --help` for the CLI.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
_STEPS_TO_PARQUET = Path(__file__).resolve().parent / "steps_to_parquet.py"
|
||||
|
||||
# Must match scripts/bump_dataset_version.py's GEN_RE / SCHEMA_RE.
|
||||
GEN_RE = re.compile(r"^gen\d+$")
|
||||
SCHEMA_RE = re.compile(r"^schema(\d+)$")
|
||||
@@ -90,17 +83,19 @@ def resolve_destination(
|
||||
return processed_gen_dir / schema_tag / detector / f"{shard_stem}.parquet"
|
||||
|
||||
|
||||
_DWARF_CONVERT_CMD = [sys.executable, "-m", "scripts.dwarf", "convert"]
|
||||
|
||||
|
||||
def _convert_one(
|
||||
root_file: str,
|
||||
batch_size: str,
|
||||
tree: str,
|
||||
compression: str,
|
||||
steps_to_parquet_path: Path,
|
||||
output_path: Path | None,
|
||||
cmd_prefix: list[str],
|
||||
) -> tuple[str, int, str, str]:
|
||||
cmd = [
|
||||
sys.executable,
|
||||
str(steps_to_parquet_path),
|
||||
*cmd_prefix,
|
||||
root_file,
|
||||
"--batch-size",
|
||||
batch_size,
|
||||
@@ -122,20 +117,25 @@ def run_parallel(
|
||||
batch_size: str = "100 MB",
|
||||
tree: str = "Steps",
|
||||
compression: str = "snappy",
|
||||
steps_to_parquet_path: Path = _STEPS_TO_PARQUET,
|
||||
output_for: dict[str, Path] | None = None,
|
||||
cmd_prefix: list[str] | None = None,
|
||||
) -> list[tuple[str, int, str, str]]:
|
||||
"""Run one steps_to_parquet.py subprocess per file, up to *jobs* at a time.
|
||||
"""Run one `dwarf convert` subprocess per file, up to *jobs* at a time.
|
||||
|
||||
*output_for*, if given, maps each root_file to the parquet path it should
|
||||
be written to (passed through as steps_to_parquet.py's --output); files
|
||||
missing from the map fall back to steps_to_parquet.py's own default
|
||||
(parquet written next to the input .root).
|
||||
be written to (passed through as `dwarf convert`'s --output); files
|
||||
missing from the map fall back to `dwarf convert`'s own default (parquet
|
||||
written next to the input .root).
|
||||
|
||||
*cmd_prefix* overrides the subprocess command run per file (defaults to
|
||||
`python -m scripts.dwarf convert`) — used by tests to substitute a fake
|
||||
conversion script.
|
||||
|
||||
Returns one (root_file, returncode, stdout, stderr) tuple per file, in
|
||||
completion order (not necessarily input order).
|
||||
"""
|
||||
output_for = output_for or {}
|
||||
cmd_prefix = cmd_prefix if cmd_prefix is not None else _DWARF_CONVERT_CMD
|
||||
results = []
|
||||
with ThreadPoolExecutor(max_workers=jobs) as pool:
|
||||
futures = {
|
||||
@@ -145,8 +145,8 @@ def run_parallel(
|
||||
batch_size,
|
||||
tree,
|
||||
compression,
|
||||
steps_to_parquet_path,
|
||||
output_for.get(f),
|
||||
cmd_prefix,
|
||||
): f
|
||||
for f in root_files
|
||||
}
|
||||
@@ -162,90 +162,52 @@ def run_parallel(
|
||||
return results
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert many ROOT files to Parquet in parallel via steps_to_parquet.py."
|
||||
)
|
||||
parser.add_argument("root_files", nargs="+", help="Input ROOT file(s)")
|
||||
parser.add_argument(
|
||||
"-j",
|
||||
"--jobs",
|
||||
type=int,
|
||||
default=4,
|
||||
help="Number of conversions to run in parallel (default: 4)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
default="100 MB",
|
||||
help="Uproot read batch size (default: '100 MB'). E.g. '50 MB', '500000' (rows).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--tree",
|
||||
default="Steps",
|
||||
help="Tree name inside the ROOT file (default: Steps)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--compression",
|
||||
default="snappy",
|
||||
choices=["snappy", "lz4", "zstd", "gzip", "none"],
|
||||
help="Parquet compression codec (default: snappy)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dataset-root",
|
||||
default="/ceph/lbogner/geant_steps",
|
||||
help="Dataset root containing raw/ and processed/ (default: /ceph/lbogner/geant_steps)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--schema",
|
||||
default=None,
|
||||
help="Schema tag to write parquets under, e.g. schema2 "
|
||||
"(default: highest schemaN already under processed/<kind>/<gen>/)",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.jobs < 1:
|
||||
parser.error("--jobs must be >= 1")
|
||||
|
||||
dataset_root = Path(args.dataset_root)
|
||||
def run_parallel_job(
|
||||
root_files: list[str],
|
||||
jobs: int,
|
||||
dataset_root: Path,
|
||||
schema: str | None,
|
||||
batch_size: str,
|
||||
tree: str,
|
||||
compression: str,
|
||||
) -> None:
|
||||
"""Resolve each file's dataset-layout destination, convert in parallel, and
|
||||
report results. Exits the process (via SystemExit) on destination or
|
||||
conversion failure — this is the top-level entry point `dwarf convert`
|
||||
delegates to when --jobs > 1."""
|
||||
output_for: dict[str, Path] = {}
|
||||
errors: list[str] = []
|
||||
for f in args.root_files:
|
||||
for f in root_files:
|
||||
try:
|
||||
output_for[f] = resolve_destination(Path(f), dataset_root, args.schema)
|
||||
output_for[f] = resolve_destination(Path(f), dataset_root, schema)
|
||||
except DestinationError as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
if errors:
|
||||
for err in errors:
|
||||
print(f"error: {err}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise SystemExit(1)
|
||||
|
||||
for root_file, dest in output_for.items():
|
||||
print(f"{root_file} -> {dest}")
|
||||
|
||||
results = run_parallel(
|
||||
args.root_files,
|
||||
jobs=args.jobs,
|
||||
batch_size=args.batch_size,
|
||||
tree=args.tree,
|
||||
compression=args.compression,
|
||||
root_files,
|
||||
jobs=jobs,
|
||||
batch_size=batch_size,
|
||||
tree=tree,
|
||||
compression=compression,
|
||||
output_for=output_for,
|
||||
)
|
||||
|
||||
failures = [root_file for root_file, code, _, _ in results if code != 0]
|
||||
if failures:
|
||||
print(f"\n{len(failures)} of {len(results)} conversion(s) failed:", file=sys.stderr)
|
||||
print(
|
||||
f"\n{len(failures)} of {len(results)} conversion(s) failed:",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for root_file in failures:
|
||||
print(f" {root_file}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raise SystemExit(1)
|
||||
|
||||
print(f"\nAll {len(results)} conversion(s) completed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user