Skip to content

ALL - IMPROVEMENT - Self-describing HDF5 metadata (issue #226, PR B) - #364

Merged
logan-nc merged 14 commits into
developfrom
refactor/hdf5-metadata
Aug 15, 2026
Merged

ALL - IMPROVEMENT - Self-describing HDF5 metadata (issue #226, PR B)#364
logan-nc merged 14 commits into
developfrom
refactor/hdf5-metadata

Conversation

@logan-nc

Copy link
Copy Markdown
Collaborator

Summary

PR B of the two stacked PRs closing #226 (stacked on #363review/merge that first; this diff is additive on top of it). Every gpec.h5 dataset now answers "what is this, in what units, plotted against what" without opening the source — netCDF-grade, natively readable by h5py/xarray/HDFView.

What every dataset gets

  • long_name — plain-text physics description.
  • units — SI strings; "1" for dimensionless (CF convention); normalized quantities state the normalization in the long_name.
  • dims (rank ≥ 2) — greppable axis-name string in Julia (column-major) order, e.g. "(psi, mode)".
  • HDF5 Dimension Scales — coordinate datasets (ψ_N grids, rational-surface ψ, geometry xs/ys, KF quadrature ψ) marked via h5ds_set_scale and attached per axis with labels: h5py .dims, xarray, and HDFView resolve axes natively (verified with h5py: DIMENSION_LIST + labels present).
  • Root attributesschema_version="2.0" (readers get a dispatch point for future breaking changes), Conventions="GPEC-HDF5-2.0", references, title, date_created (ISO 8601 UTC).

Exempt (whitelisted in the enforcement test): Input/** (raw rerun snapshot) and GalerkinIntegration/Match/** (debug-only, drop pending Galerkin-owner decision — no point hand-curating ~15 entries for it).

Mechanism — one deviation from the approved plan wording

The plan said "route all writers through a write_annotated! helper". Implemented instead as table-driven post-hoc annotation: each writer keeps a path => (; long_name, units, dims) table next to it and applies it with one annotate! call after its datasets are written (Utilities.HDF5Annotations + src/HDF5Schema.jl for the main writer). Same contract, enforced identically by the schema test — but the writer bodies are untouched, keeping this PR genuinely low-conflict with #354/#345/#339/#318 and handling dynamic writes (per-field equilibrium loop, per-method KF groups) naturally. ~340 annotation entries total.

Physics review

The tables were audited by the fortran-physics-reviewer agent against the layer/field-reconstruction sources and the Fortran gpout.f attribute crib; 27 corrections applied, including: J-weighted contravariant fields are T*m^2 (χ₁-weighted), not T; Tearing/PerSurface/tauk is the Q-normalization time S^⅓·τ_H, not a resistive-kink time; island_width_sq is the squared half-width; Roots/omega_Hz is actually an angular frequency (rad/s) — the dataset name is a misnomer, flagged here rather than renamed (renaming is a breaking change; candidate for a future schema bump); delta_n is the Δ′-normalization S^⅓/r_s in 1/m. Reviewers: the covariant-component units (T*m, m^2) were settled by dimensional analysis — worth a second look.

Enforcement

test/runtests_h5_schema.jl now asserts on a full-run output: long_name+units on every non-exempt dataset, dims on every rank ≥ 2 array, root attributes present, and the ψ_N scale attached (28/28 passing). Additional metadata walks were run on the DIIID SLAYER, Solovev kinetic-NTV, and DIIID gal-resistive decks — zero violations. When adding a dataset, add its table entry in the same commit or the test fails — this is the anti-drift teeth #226 asked for.

Notes

  • One HDF5.jl wart worked around: the h5ds_set_label wrapper mis-types the C const char* as Ref{UInt8}; we pass a NUL-terminated byte buffer.
  • Dates (stdlib) added to Project.toml for date_created.
  • Known gap (pre-existing): LocalStability/di/dr have no stored coordinate grid to attach (their ψ grid isn't written); their dims attribute documents the axis name only.

Regression harness (mandatory gate) — zero movement

regress --cases <all 12> --refs develop,local: identical table to PR A — every surviving quantity OK at 0.0e+00 (48/21/15/16/10/4+4 pre-existing N/A/14/14/6/5/4/4). Attributes are invisible to the extractor and the rerun leaf-walk, as designed; the bit-for-bit rerun round-trip test passes with annotations present.

Other verification

Full schema test 28/28; SLAYER runner, coils, fullruns, rerun-from-h5 all pass; docs build clean (new HDF5Annotations autodocs covered); h5py external check confirms scales/attrs.

⚠️⚠️ NO MERGE WITHOUT THIRD-PARTY HUMAN REVIEW ⚠️⚠️

This PR must not be merged until a third-party human reviewer has approved it — non-negotiable project policy. Merge order: #363 first, then this.

🤖 Generated with Claude Code

https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz

logan-nc and others added 2 commits August 12, 2026 20:38
…dims, scales)

Every gpec.h5 dataset outside Input/ and the debug-only GalerkinIntegration/Match/
now answers "what is this, in what units, plotted against what" without opening
the source, readable natively by h5py/xarray/HDFView:

- New Utilities.HDF5Annotations: annotate!(parent, table) applies per-writer
  path => (; long_name, units, dims) tables post-write (skipping absent paths);
  make_scale!/attach_scale! wrap the H5DS dimension-scale API (netCDF-4
  coordinate mechanism) with Julia-axis -> C-dim index translation;
  write_root_attrs! stamps schema_version=2.0, Conventions=GPEC-HDF5-2.0,
  references, title, date_created.
- Table-driven, not per-write-call: writers are untouched except one annotate
  call at the end of each (src/HDF5Schema.jl for the main writer; tables live
  next to write_galerkin!, the PE writer, KineticForces/Output.jl, and
  Tearing/Runner/HDF5Output.jl). ~340 dataset annotations total.
- Coordinate datasets (psi grids, rational-surface psi, geometry xs/ys) are
  marked as HDF5 Dimension Scales and attached to the profiles sharing the axis;
  a greppable "dims" attribute mirrors the scales in Julia axis order.
- Attribute wording/units audited by the fortran-physics-reviewer against the
  layer/field-reconstruction sources; 27 corrections applied (J-weighted field
  units T*m^2, Q-normalization time vs resistive-kink time, island half-width
  vs full width, omega_Hz actually rad/s, etc.).
- runtests_h5_schema.jl now enforces the contract: long_name+units on every
  non-exempt dataset, dims on rank >= 2 arrays, root attrs present, scales
  attached; docs/development/hdf5-conventions.md gains the metadata contract.

Attributes are invisible to the rerun leaf-walk and the regression extractor:
no tracked value moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz
@logan-nc

Copy link
Copy Markdown
Collaborator Author

Full test/runtests.jl suite (all 57 testsets) run on this branch @ 0ece9c4: all pass, zero failures/errors. Together with the harness table in the description (zero movement, identical to #363's), the approved plan's verification checklist is complete.

@logan-nc

Copy link
Copy Markdown
Collaborator Author

@d-burg please review your outputs carefully here. In particular, the PR overview points out:

Roots/omega_Hz is actually an angular frequency (rad/s) — the dataset name is a misnomer, flagged here rather than renamed (renaming is a breaking change; candidate for a future schema bump);

It looks like you also have a delta_n for the Δ′-normalization S^⅓/r_s in 1/m? This seems dangerously close to delta_mlow, delta_mhigh which means likely confusion when @jhalpern30 extends the code to full 3D (thus perhaps getting delta_nlow type settings? Maybe not exactly that, but still...). How about changing your output to something more unique like delta_prime_norm? In general, please be explicit about prime things - there are far too many deltas in GPEC to be loosey goosey with primes! Apply the fixes on this branch rather than elsewhere to minimize merge conflicts, since the h5 changes have a stack of PRs going.

@logan-nc

Copy link
Copy Markdown
Collaborator Author

@d-burg is voluntold to do the full diff review on this one since he has some specific things called out.
@matt-pharr is in charge of checking units and long names (feel free to do more if interested).
@jhalpern30 please concentrate on the general h5 structuring logic (feel free to do more if interested).

@d-burg d-burg left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed with a focus on the Tearing/ metadata table, since this PR's annotations become the machine-readable statement of record for the tearing outputs. Scope: I audited the Tearing/ table exhaustively against the writer bodies, the Fortran GPEC slayer/ sources, and Park 2022; I spot-checked the PerturbedEquilibrium, KineticForces and Galerkin tables; I did not audit the main HDF5Schema.jl table. A green review here should not be read as coverage of that.

The mechanism is sound — table-driven post-hoc annotation is the right call for low-conflict stacking, and the table is genuinely complete: I diffed it field-by-field against both _write_per_surface! methods, _write_roots!, _write_layer_widths!, the ragged Diagnostics encoding and both _write_single_scan! branches, and nothing is missing. Requesting changes on one physics error and one enforcement gap.

Blocking

1. PerSurface/tauk states the wrong sign convention (inline suggestion below)

The (Q = −τ_k·ω) parenthetical generalizes a diamagnetic-only minus sign onto the mode frequency. Fortran GPEC slayer/params.f:47-52 is unambiguous:

Qconv=lu**(1.0/3.0)*tau_h ! conversion to Qs based on Cole
Q=Qconv*omega
Q_e=-Qconv*omega_e
Q_i=-Qconv*omega_i

Q = +Qconv·ω for the mode; the minus on Q_e/Q_i exists only to turn the negative-by-convention omega_e (params.f:42) into Q_e > 0, exactly as LayerParameters.jl:228-241 already documents and scopes correctly. Park 2022 agrees independently: Q = S^(1/3)·ω_E·τ_H, positive, with the perturbation taken as ψ ∝ e^(iky − iωt).

As written, this annotation tells every downstream reader that Roots/omega_Hz carries the opposite sign from what GrowthRateExtraction.jl:749 actually produces. I traced the full extraction path — _run_analysis, the AMR contour tracer, the contour intersection code — looking for a compensating flip, and there is none; Im(Q) > 0 = unstable falls out of Q = +τ_k·ω directly. The code is right and the annotation is wrong.

2. The metadata contract never walks Tearing/

runtests_h5_schema.jl runs test_data/regression_solovev_ideal_example, which is ideal-only — and the Solovev equilibrium cannot host tearing at all (examples/Solovev_ideal_example/gpec.toml:47-50: "SLAYER tearing analysis is not run on the Solovev analytic equilibrium"). No committed test deck has a [tearing] section. So _collect_metadata_violations never sees a Tearing/ group, and the PR's "add the table entry in the same commit or the test fails" guarantee does not hold for this module. The DIIID-SLAYER walk cited in the description was a one-time manual run and won't catch drift.

No new fixture is needed. The existing write_slayer_hdf5!: round-trip structure testset (test/runtests_slayer_runner.jl:149) already builds a synthetic two-surface SLAYERResult with store_scan=true and writes it to a temp file — covering PerSurface/, Roots/, LayerWidths/, Diagnostics/ and Scan/ with no equilibrium solve. Three lines close the gap at ~zero runtime cost:

         mktemp() do path, io
             close(io)
             h5open(path, "w") do f
                 write_slayer_hdf5!(f, r)
             end
+
+            # Metadata contract must hold for the Tearing group too — the full-run
+            # schema test only exercises an ideal deck, which writes no Tearing/.
+            h5open(path, "r") do f
+                viol = _collect_metadata_violations(f)
+                isempty(viol) || @error "metadata contract violations in Tearing/" viol
+                @test isempty(viol)
+            end

with include("h5_metadata_check.jl") alongside the file's other includes (see the inline suggestion on runtests_h5_schema.jl for that shared helper). Note this covers the SLAYER branch only — PerSurface/taua/taur/v1 are reachable only through the GGJ writer, so if there's no separate GGJ writer testset those three stay unenforced.

The same pattern generalizes to write_galerkin! and KineticForces.write_to_hdf5!, which are pure writers too, but that's beyond what this PR needs to be safe.

Non-blocking, outside this diff

SLAYERParameters' docstring has two rows that this PR (correctly) contradicts. Worth fixing here so the file and the docstring don't ship disagreeing:

--- a/src/InnerLayer/SLAYER/LayerParameters.jl
+++ b/src/InnerLayer/SLAYER/LayerParameters.jl
@@ -33,7 +33,7 @@
-| `tauk`     | Q-conversion factor S^(1/3) · τ_H  [s] — multiplies ω to get Q    |
+| `tauk`     | Q-conversion factor S^(1/3) · τ_H  [s]: Q = tauk · ω (ω in rad/s) |
@@ -40,8 +40,8 @@
-| `dr_val`   | Radial width parameter at surface (input to dc_tmp)               |
-| `dgeo_val` | Geometric Δ (Shafranov shift factor)                              |
+| `dr_val`   | Resistive interchange index D_R = E + F + H² (input to dc_tmp)    |
+| `dgeo_val` | Connor-Hastie-Helander 2015 Eq. 59 geometric factor               |

dr_val/dgeo_val are settled by LayerInputs.jl:95-99, Control.jl:39-40 and GGJParameters.jl:56the PR's wording is the correct one, the docstring is what's stale.

Confirming the calls that are right

Several judgement calls in this PR are correct and I'd rather they not get relitigated alongside the one that isn't:

  • Roots/omega_Hz as rad/s is right. Park's ω enters as e^(−iωt), so it is angular by construction, and Qconv is in seconds. The _Hz in the dataset name is the misnomer, not the units attribute.
  • The rad/s vs 1/s asymmetry between ω and γ is right and principled — the cycles-vs-radians distinction only bites for oscillation; an e-folding rate is the same number either way.
  • Not renaming omega_Hz/gamma_Hz here is right — that's a breaking output-schema change and belongs behind a schema_version bump.
  • tauk = S^(1/3)·τ_H, delta_n = S^(1/3)/r_s in 1/m, tau_r = μ₀r_s²/η, c_beta = √(β/(1+β)), D_norm, sval_r, d_beta = c_β·d_i — all verified against params.f and LayerParameters.jl. Correct.
  • The brute-force scan dims="(re_axis, im_axis)" matches ScanResult.Q::Matrix being nre × nim in Julia order. Correct.

Filed separately

The units audit surfaced a pre-existing 2π bug on develop: GrowthRateExtraction.jl:441 converts a rad/s quantity to "kHz" with only a 1e-3 factor and compares it against gap_kHz_threshold. Filed as #372 rather than loaded onto this PR — it only gates the :gap warning flag, so no value and no regression-harness quantity moves.

Comment thread src/Tearing/Runner/HDF5Output.jl Outdated
"PerSurface/Q_e" => (; long_name="normalized electron diamagnetic frequency Q_e per surface", dims=("surface",)),
"PerSurface/Q_i" => (; long_name="normalized ion diamagnetic frequency Q_i per surface", dims=("surface",)),
"PerSurface/iota_e" => (; long_name="electron fraction ι_e = Q_e/(Q_e − Q_i) per surface", dims=("surface",)),
"PerSurface/tauk" => (; long_name="Q-normalization time S^(1/3)·τ_H per surface (Q = −τ_k·ω)", units="s", dims=("surface",)),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A1 — blocking. See the review summary: Fortran slayer/params.f:50 is Q=Qconv*omega (plus sign); the minus belongs only to Q_e/Q_i on lines 51-52.

Suggested change
"PerSurface/tauk" => (; long_name="Q-normalization time S^(1/3)·τ_H per surface (Q = τ_k·ω)", units="s", dims=("surface",)),
"PerSurface/tauk" => (; long_name="Q-normalization time S^(1/3)·τ_H per surface (Q = τ_k·ω; the diamagnetic inputs Q_e, Q_i carry the opposite sign by convention)", units="s", dims=("surface",)),

Comment thread src/Tearing/Runner/HDF5Output.jl Outdated
"PerSurface/Q_i" => (; long_name="normalized ion diamagnetic frequency Q_i per surface", dims=("surface",)),
"PerSurface/iota_e" => (; long_name="electron fraction ι_e = Q_e/(Q_e − Q_i) per surface", dims=("surface",)),
"PerSurface/tauk" => (; long_name="Q-normalization time S^(1/3)·τ_H per surface (Q = −τ_k·ω)", units="s", dims=("surface",)),
"PerSurface/tau_r" => (; long_name="resistive diffusion time τ_R per surface", units="s", dims=("surface",)),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A2. tau_r is written by the SLAYER _write_per_surface! (line 164) and taur by the GGJ one (line 185). They never coexist, but they currently carry byte-identical long_names, so the annotation can't tell a reader which branch produced their file.

Suggested change
"PerSurface/tau_r" => (; long_name="resistive diffusion time τ_R per surface", units="s", dims=("surface",)),
"PerSurface/tau_r" => (; long_name="resistive diffusion time τ_R = μ₀r_s²/η per surface (SLAYER layer parameters)", units="s", dims=("surface",)),

Comment thread src/Tearing/Runner/HDF5Output.jl Outdated
Comment on lines +90 to +91
"PerSurface/taua" => (; long_name="Alfvén time τ_A per surface", units="s", dims=("surface",)),
"PerSurface/taur" => (; long_name="resistive diffusion time τ_R per surface", units="s", dims=("surface",)),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A3. Same disambiguation on the GGJ side.

Suggested change
"PerSurface/taua" => (; long_name="Alfvén time τ_A per surface", units="s", dims=("surface",)),
"PerSurface/taur" => (; long_name="resistive diffusion time τ_R per surface", units="s", dims=("surface",)),
"PerSurface/taua" => (; long_name="Alfvén time τ_A per surface (GGJ layer parameters)", units="s", dims=("surface",)),
"PerSurface/taur" => (; long_name="resistive diffusion time τ_R per surface (GGJ layer parameters)", units="s", dims=("surface",)),

Comment thread src/Tearing/Runner/HDF5Output.jl Outdated
Comment on lines +95 to +96
"Roots/Q_root_real" => (; long_name="Re of the dispersion-root normalized frequency Q (NaN = no root)"),
"Roots/Q_root_imag" => (; long_name="Im of the dispersion-root normalized frequency Q (NaN = no root)"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A5. Roots/* are the only rank-1 tearing datasets without dims, which breaks the greppability the attribute exists for. (Length-1 in coupled mode, length-msing in uncoupled — surface is right for both.)

Suggested change
"Roots/Q_root_real" => (; long_name="Re of the dispersion-root normalized frequency Q (NaN = no root)"),
"Roots/Q_root_imag" => (; long_name="Im of the dispersion-root normalized frequency Q (NaN = no root)"),
"Roots/Q_root_real" => (; long_name="Re of the dispersion-root normalized frequency Q (NaN = no root)", dims=("surface",)),
"Roots/Q_root_imag" => (; long_name="Im of the dispersion-root normalized frequency Q (NaN = no root)", dims=("surface",)),

Comment thread src/Tearing/Runner/HDF5Output.jl Outdated
Comment on lines +97 to +98
"Roots/omega_Hz" => (; long_name="mode rotation angular frequency of each root", units="rad/s"),
"Roots/gamma_Hz" => (; long_name="growth rate of each root", units="1/s"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A4. Both units calls here are correct — this just records why, so nobody "fixes" them later, and adds the one invariant a downstream reader most needs (positive = unstable), which the file currently states nowhere.

Suggested change
"Roots/omega_Hz" => (; long_name="mode rotation angular frequency of each root", units="rad/s"),
"Roots/gamma_Hz" => (; long_name="growth rate of each root", units="1/s"),
"Roots/omega_Hz" => (; long_name="mode rotation angular frequency ω = Re(Q)/τ_k of each root (the dataset name is a misnomer: this is angular, not cycles/s)", units="rad/s", dims=("surface",)),
"Roots/gamma_Hz" => (; long_name="growth rate γ = Im(Q)/τ_k of each root, positive = unstable (an e-folding rate, so no 2π distinction applies)", units="1/s", dims=("surface",)),

Comment thread src/Tearing/Runner/HDF5Output.jl Outdated
"Roots/Q_root_imag" => (; long_name="Im of the dispersion-root normalized frequency Q (NaN = no root)"),
"Roots/omega_Hz" => (; long_name="mode rotation angular frequency of each root", units="rad/s"),
"Roots/gamma_Hz" => (; long_name="growth rate of each root", units="1/s"),
"Roots/no_root" => (; long_name="flag: no usable dispersion root found (Q_root is NaN, ω/γ are placeholders)"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A5 (cont.)dims on the remaining Roots/ entry.

Suggested change
"Roots/no_root" => (; long_name="flag: no usable dispersion root found (Q_root is NaN, ω/γ are placeholders)"),
"Roots/no_root" => (; long_name="flag: no usable dispersion root found (Q_root is NaN, ω/γ are placeholders)", dims=("surface",)),

Comment thread src/Tearing/Runner/HDF5Output.jl Outdated
"PerSurface/eta" => (; long_name="parallel resistivity at each surface", units="Ohm*m", dims=("surface",)),
"PerSurface/d_beta" => (; long_name="β-weighted ion drift scale d_β", units="m", dims=("surface",)),
"PerSurface/dc_tmp" => (; long_name="critical-Δ offset from χ_∥/χ_⊥ matching (Connor-Hastie-Helander 2015 Eq. 59)", dims=("surface",)),
"PerSurface/dc_type" => (; long_name="per-surface D_c prescription label"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A6dims for consistency with the rest of PerSurface/.

Suggested change
"PerSurface/dc_type" => (; long_name="per-surface D_c prescription label"),
"PerSurface/dc_type" => (; long_name="per-surface D_c prescription label", dims=("surface",)),

Comment on lines 35 to +38
g = create_group(parent, "Tearing")
g["enabled"] = Int(result.enabled)

result.enabled || return g # nothing else to write
if !result.enabled # nothing else to write

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A7. The SLAYER and GGJ _write_per_surface! methods write disjoint field sets, so today the only way to tell which model produced a file is to notice which PerSurface datasets exist. Inferring the model from the shape of the schema is exactly what self-describing metadata should eliminate — and it makes A2/A3 a convenience rather than the sole signal. A group attribute keeps this out of the dataset walk, so the schema test is unaffected.

Suggested change
g = create_group(parent, "Tearing")
g["enabled"] = Int(result.enabled)
result.enabled || return g # nothing else to write
if !result.enabled # nothing else to write
g = create_group(parent, "Tearing")
g["enabled"] = Int(result.enabled)
# Which inner-layer model produced PerSurface/: the SLAYER and GGJ branches write
# disjoint field sets, so readers must not have to infer it from the schema.
attrs(g)["layer_model"] = result.enabled ? _layer_model_token(eltype(result.params)) : "none"
if !result.enabled # nothing else to write

with the helper added next to the annotation tables (e.g. just above TEARING_H5_ANNOTATIONS):

# Token recorded in the Tearing group's `layer_model` attribute.
_layer_model_token(::Type{<:SLAYERParameters}) = "slayer"
_layer_model_token(::Type{<:GGJParameters}) = "ggj"
_layer_model_token(::Type) = "unknown"

GGJParameters is already in scope here via the _write_per_surface! dispatch, and empty_slayer_result builds SLAYERParameters[] even when disabled, hence the result.enabled guard.

Comment thread test/runtests_h5_schema.jl Outdated
Comment on lines +35 to +58
# Metadata contract (docs/development/hdf5-conventions.md): every dataset carries
# long_name + units, and rank ≥ 2 datasets carry a dims axis-name attribute. Exempt:
# the Input/ raw snapshot and the debug-only GalerkinIntegration Match/ group.
_metadata_exempt(path) = startswith(path, "Input/") || occursin("/Match/", path)

function _collect_metadata_violations(h5)
bad = String[]
function walk(node, prefix)
for k in keys(node)
child = node[k]
full = isempty(prefix) ? k : prefix * "/" * k
if child isa HDF5.Group
walk(child, full)
elseif !_metadata_exempt(full)
a = attrs(child)
haskey(a, "long_name") || push!(bad, "$full: missing long_name")
haskey(a, "units") || push!(bad, "$full: missing units")
ndims(child) >= 2 && !haskey(a, "dims") && push!(bad, "$full: missing dims")
end
end
end
walk(h5, "")
return bad
end

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

B2 — needed for the blocking enforcement fix. Lift this walk verbatim into a shared test/h5_metadata_check.jl so runtests_slayer_runner.jl can call it too (see the review summary). No behaviour change here.

Suggested change
# Metadata contract (docs/development/hdf5-conventions.md): every dataset carries
# long_name + units, and rank ≥ 2 datasets carry a dims axis-name attribute. Exempt:
# the Input/ raw snapshot and the debug-only GalerkinIntegration Match/ group.
_metadata_exempt(path) = startswith(path, "Input/") || occursin("/Match/", path)
function _collect_metadata_violations(h5)
bad = String[]
function walk(node, prefix)
for k in keys(node)
child = node[k]
full = isempty(prefix) ? k : prefix * "/" * k
if child isa HDF5.Group
walk(child, full)
elseif !_metadata_exempt(full)
a = attrs(child)
haskey(a, "long_name") || push!(bad, "$full: missing long_name")
haskey(a, "units") || push!(bad, "$full: missing units")
ndims(child) >= 2 && !haskey(a, "dims") && push!(bad, "$full: missing dims")
end
end
end
walk(h5, "")
return bad
end
include("h5_metadata_check.jl")

New file test/h5_metadata_check.jl:

# Metadata contract (docs/development/hdf5-conventions.md): every dataset carries
# long_name + units, and rank ≥ 2 datasets carry a dims axis-name attribute. Exempt:
# the Input/ raw snapshot and the debug-only GalerkinIntegration Match/ group.
_metadata_exempt(path) = startswith(path, "Input/") || occursin("/Match/", path)

function _collect_metadata_violations(h5)
    bad = String[]
    function walk(node, prefix)
        for k in keys(node)
            child = node[k]
            full = isempty(prefix) ? k : prefix * "/" * k
            if child isa HDF5.Group
                walk(child, full)
            elseif !_metadata_exempt(full)
                a = attrs(child)
                haskey(a, "long_name") || push!(bad, "$full: missing long_name")
                haskey(a, "units") || push!(bad, "$full: missing units")
                ndims(child) >= 2 && !haskey(a, "dims") && push!(bad, "$full: missing dims")
            end
        end
    end
    walk(h5, "")
    return bad
end

@d-burg

d-burg commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Follow-up on the blocking enforcement gap — it now has a concrete instance rather than a hypothetical one.

#298 (FKR-width, resistive-layer-width sanity checks) adds two fields to LayerWidths:

δ_FKR   = p.rs * p.lu^(-1.0/3.0)
δ_visco = δ_FKR * p.P_perp^(1.0/6.0)

It doesn't touch src/Tearing/Runner/HDF5Output.jl, so nothing collides today — the fields are computed but never written. But the moment they're wired into _write_layer_widths! (which is the obvious next step for a diagnostic like this), they land in Tearing/LayerWidths/ with no entry in TEARING_H5_ANNOTATIONS, and runtests_h5_schema.jl still passes green — because the deck it runs is ideal-only and never produces a Tearing/ group at all.

That's the failure mode the three-line fix in runtests_slayer_runner.jl:149 prevents, and #298 is a live PR that would walk straight into it.

Two clarifications while I'm here, so nothing above reads as a criticism of work that's actually correct:

Also worth flagging for whoever rebases: #368's fix to the legacy haskey(f["singular"], ...) calls in the gal_validation scans is moot now that develop has deleted those files.

logan-nc and others added 2 commits August 14, 2026 16:32
Brings the remediated PR-A base (second develop sync, independent-review
fixes, reviewer dispositions) up the stack. Two content conflicts:

- src/GeneralizedPerturbedEquilibrium.jl module header: base dropped the H5_*
  writer/rerun consts; this branch adds include("HDF5Schema.jl") in the same
  region. Kept the include, dropped the consts.
- test/runtests_h5_schema.jl: base added the _group_name_ok unit testset where
  this branch adds the metadata-contract helpers. Kept both.

Post-merge: no conflict markers, no stale H5_* references, package loads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz
Review remediation for the Tearing/ annotation table and its enforcement:

- Fix the tauk sign convention: Q = +tau_k*omega per Fortran slayer/params.f
  (the minus applies only to the diamagnetic inputs Q_e/Q_i); align the two
  contradicted SLAYERParameters docstring rows (dr_val, dgeo_val) with the
  corrected annotations.
- Record why omega_Hz/gamma_Hz carry rad/s and 1/s (dataset names are
  historical misnomers) and the positive = unstable invariant in long_names.
- Disambiguate tau_r (SLAYER) from taua/taur (GGJ) long_names; add dims to
  Roots/* and PerSurface/dc_type.
- New Tearing group attribute layer_model ("slayer"/"ggj"/"none") so readers
  need not infer the inner-layer model from which PerSurface fields exist.
- Lift the metadata-contract walker into shared test/h5_metadata_check.jl and
  enforce it from the SLAYER round-trip testset (the full-run schema test only
  exercises an ideal deck, which writes no Tearing/) plus a synthetic
  GGJParameters write covering the GGJ-only table rows.
- Drop a stale doc sentence referencing the removed H5_* consts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz
@logan-nc

Copy link
Copy Markdown
Collaborator Author

Branch updated 0ece9c4f..2bb72589: merged the remediated PR-A base (second develop sync + independent-review fixes + reviewer dispositions — see the #363 thread) and addressed this review point-by-point:

Blocking 1 (tauk sign) — applied your suggested wording verbatim (modulo a 3-word trim for the 180-char margin): Q = τ_k·ω, minus scoped to the diamagnetic inputs only. You were right and the "applied correction" was wrong — thank you for tracing the extraction path. Also fixed the two contradicted SLAYERParameters docstring rows (dr_val, dgeo_val) per your non-blocking diff.

Blocking 2 (Tearing/ never walked) — the walker now lives in shared test/h5_metadata_check.jl, included by both runtests_h5_schema.jl and runtests_slayer_runner.jl. The existing synthetic round-trip testset now walks its temp file (your 3-line suggestion, plus a layer_model assert), and a new synthetic GGJParameters write covers the GGJ-only rows (taua/taur/v1) you flagged as otherwise unenforced. SLAYER runner: 65/65.

A1–A7 — all applied: tau_r/taur disambiguated with branch labels; dims=("surface",) on all Roots/* and dc_type; the rad/s / 1/s rationale + positive = unstable invariant recorded; and the layer_model group attribute ("slayer"/"ggj"/"none") from your A7 suggestion, via _layer_model_token(eltype(result.params)).

Note the stack base has moved (#363 got a second develop sync + the legacy-map drop per review there) — the metadata mechanism here is untouched by that, but the diff base shifted. Full targeted suites green on this tip (schema 14/14 + 6/6, SLAYER 65/65).

Requesting re-review.

🤖 Generated with Claude Code

https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz

@logan-nc
logan-nc requested a review from d-burg August 14, 2026 20:42
Base automatically changed from refactor/hdf5-module-schema to develop August 14, 2026 20:47
Compute.jl stores total_energy = Im(T)/(2n), i.e. plain deltaW_k in joules;
the long_name wrongly claimed 2n*deltaW_k. The psi-profiles (T_imag,
dTdpsi_imag) ARE raw Im(T) and keep the 2n label — both long_names now state
the asymmetry explicitly so neither gets 'corrected' to match the other.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz
@logan-nc

Copy link
Copy Markdown
Collaborator Author

One more annotation correction pushed (ca870696), caught by @logan-nc: KineticForces/<method>/total_energy stores δW_k with the 2n already divided out (Compute.jl: imag(total_torque) / (2*ctrl.nn)) — the long_name wrongly claimed 2n·δW_k. The ψ-profiles (T_imag, dTdpsi_imag) are raw Im(T) and keep the 2n label; both long_names now state the scalar-vs-profile asymmetry explicitly so neither gets "corrected" to match the other later.

@logan-nc logan-nc left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finding lots of human intuition issues with the AI overhaul (as expected)... posting review of SLAYER and KF now so claude can knock 'em out while I review PE. Goal is to merge tonight

Comment thread src/KineticForces/Output.jl Outdated
const KF_METHOD_H5_ANNOTATIONS = [
"nn" => (; long_name="toroidal mode number n of this torque calculation"),
"total_torque" => (; long_name="total NTV toroidal torque T_φ", units="N*m"),
"total_energy" => (; long_name="total perturbed kinetic energy 2n·δW_k", units="J"),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the 2n really here? Something reported as energy should not have the 2n in it. Something reported as Imaginary component of the torque should

Comment thread src/KineticForces/Output.jl Outdated
"panel_psi" => (; long_name="ψ_N panel boundaries of the radial quadrature"),
"resonance_psi" => (; long_name="ψ_N of located kinetic-resonance surfaces"),
"psi" => (; long_name="normalized poloidal flux ψ_N at quadrature evaluation points"),
"dTdpsi_real" => (; long_name="Re dT_φ/dψ_N torque density at quadrature points", units="N*m", dims=("psi",)),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe *_real and *_imag subscripts are not necessary and torque can be stored natively as complex. Is there a reason it is broken up here? This should be standardized throughout all h5 writes... add something to the h5 instruction md?

Comment thread src/KineticForces/Output.jl Outdated
"resonance_psi" => (; long_name="ψ_N of located kinetic-resonance surfaces"),
"psi" => (; long_name="normalized poloidal flux ψ_N at quadrature evaluation points"),
"dTdpsi_real" => (; long_name="Re dT_φ/dψ_N torque density at quadrature points", units="N*m", dims=("psi",)),
"dTdpsi_imag" => (; long_name="Im dT_φ/dψ_N (2n·dδW_k/dψ_N energy density) at quadrature points", units="J", dims=("psi",)),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most other codes report dT/dV the torque density [N/m^2]. Lets make sure we have dVdpsi in the outputs so this is easily available

| `sval_r` | r-based magnetic shear r_s · (dq/dr) / q (Fitzpatrick convention) |
| `dr_val` | Radial width parameter at surface (input to dc_tmp) |
| `dgeo_val` | Geometric Δ (Shafranov shift factor) |
| `dr_val` | Resistive interchange D_R = E + F + H² (critical-Δ input; auto-derived from GGJ coefficients unless overridden) |

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@d-burg should confirm this meaningful change and suggest new names for this an other *_val SLAYER outputs. IMO the _val subscripts add nothing but are taking room. This could be Interchange_D_R or D_R_interchange for example.

Comment thread src/KineticForces/Output.jl Outdated
"dTdpsi_real" => (; long_name="Re dT_φ/dψ_N torque density at quadrature points", units="N*m", dims=("psi",)),
"dTdpsi_imag" => (; long_name="Im dT_φ/dψ_N (2n·dδW_k/dψ_N energy density) at quadrature points", units="J", dims=("psi",)),
"T_real" => (; long_name="cumulative toroidal torque T_φ(ψ_N) (trapezoidal)", units="N*m", dims=("psi",)),
"T_imag" => (; long_name="cumulative 2n·δW_k(ψ_N) (trapezoidal; raw Im(T) — not divided by 2n like total_energy)", units="J", dims=("psi",)),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parenthetical is overkill - these are still meant to be names, not general annotations

Comment thread src/KineticForces/Output.jl Outdated
"EnergyIntegrals/psi" => (; long_name="ψ_N of each energy-integration record"),
"EnergyIntegrals/lambda" => (; long_name="pitch λ = μB0/E of each record"),
"EnergyIntegrals/ell" => (; long_name="bounce harmonic ℓ of each record"),
"EnergyIntegrals/leff" => (; long_name="effective bounce harmonic ℓ_eff of each record"),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could include the actual equation for l_eff here since it is short, which would be consistent with lambda

Comment thread src/KineticForces/Output.jl Outdated
"EnergyIntegrals/leff" => (; long_name="effective bounce harmonic ℓ_eff of each record"),
"EnergyIntegrals/torque_real" => (; long_name="Re of the record's torque contribution", units="N*m"),
"EnergyIntegrals/torque_imag" => (; long_name="Im of the record's torque contribution", units="N*m"),
"EnergyIntegrals/kinetic_energy_real" => (; long_name="Re of the record's kinetic energy contribution", units="J"),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docs should explain why there are complex torque and energy here but only a complex torque (with energy corresponding to the imaginary part) at the top level of KineticForces outputs.

"EnergyIntegrals/torque_imag" => (; long_name="Im of the record's torque contribution", units="N*m"),
"EnergyIntegrals/kinetic_energy_real" => (; long_name="Re of the record's kinetic energy contribution", units="J"),
"EnergyIntegrals/kinetic_energy_imag" => (; long_name="Im of the record's kinetic energy contribution", units="J"),
"EnergyIntegrals/trajectory_offsets" => (; long_name="ragged-array offsets: record k spans offsets[k]+1:offsets[k+1] of the *_all arrays"),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems confusing for new users? A re-review should think hard about if there is a cleaner way to organize these outputs in the h5

Comment thread src/KineticForces/Output.jl Outdated
"EnergyIntegrals/integrand_imag_all" => (; long_name="Im of the energy-space torque integrand along all trajectories (concatenated)"),
"EnergyIntegrals/integral_real_all" => (; long_name="Re of the cumulative energy-space integral along all trajectories (concatenated)"),
"EnergyIntegrals/integral_imag_all" => (; long_name="Im of the cumulative energy-space integral along all trajectories (concatenated)"),
"KineticMatrices/matrix_1" => (; long_name="drift-kinetic coefficient matrix 1 of 6 (Logan 2015 Eqs 7.30-7.35)", dims=("mode", "mode")),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users familiar with the papers will know these by names like A,B, etc. while the index numbers are just an artifact of the code's consolidated storage. Rename more intuitively.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will repeated dims like (mode, mode) break interfaces we claim to be facilitating like xarray? Do we need to name them distinctly? That would introduce it's own possible confusion points. Is there an industry standard for this kind of thing?

Comment thread src/KineticForces/Output.jl Outdated
]

# Attach long_name/units/dims + the ψ_N quadrature scale to one method group.
function annotate_kinetic_forces!(method_g)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are separate helper functions defined each time? It seems like its just to do manual make_scale calls or looped calls. Couldn't that be handed by the general annotate function is we just added a (optional?) scale entry to the *_H5_ANNOTATIONS tables?

@logan-nc logan-nc left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Finished human review of Galerkin - plenty of issues for a new AI plan to address.
Now on to PE for real

const GALERKIN_H5_ANNOTATIONS = [
"ForceFreeStates/Solutions/GalerkinIntegration/msing" => (; long_name="number of rational (singular) surfaces in the Galerkin solve"),
"ForceFreeStates/Solutions/GalerkinIntegration/Solution/psi" => (; long_name="normalized poloidal flux ψ_N grid of the Galerkin solution"),
"ForceFreeStates/Solutions/GalerkinIntegration/Solution/q" => (; long_name="safety factor on the Galerkin solution grid", dims=("psi",)),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why are we outputting such a fine grid q profile? That seems excessive as we already output the q spline information it comes directly from in the Equilibrium section of the h5

"ForceFreeStates/Solutions/GalerkinIntegration/msing" => (; long_name="number of rational (singular) surfaces in the Galerkin solve"),
"ForceFreeStates/Solutions/GalerkinIntegration/Solution/psi" => (; long_name="normalized poloidal flux ψ_N grid of the Galerkin solution"),
"ForceFreeStates/Solutions/GalerkinIntegration/Solution/q" => (; long_name="safety factor on the Galerkin solution grid", dims=("psi",)),
"ForceFreeStates/Solutions/GalerkinIntegration/Solution/issing" => (; long_name="flag: grid node lies on a rational surface", dims=("psi",)),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a documented standard for variable name syntax in cases like this where the variables are words? To me, the is_singular seems most natural despite using CamelCase for the section labels... but I guess I could be convinced of IsSingular if that conforms better to standards (i.e. if the CamelCase choice means we should be CamelCase through and through). We should standardize throughout either way

"ForceFreeStates/Solutions/GalerkinIntegration/Solution/q" => (; long_name="safety factor on the Galerkin solution grid", dims=("psi",)),
"ForceFreeStates/Solutions/GalerkinIntegration/Solution/issing" => (; long_name="flag: grid node lies on a rational surface", dims=("psi",)),
"ForceFreeStates/Solutions/GalerkinIntegration/Solution/xi" => (; long_name="Galerkin solution functions ξ (arbitrary amplitude)", dims=("mode", "psi", "solution")),
"ForceFreeStates/Solutions/GalerkinIntegration/Solution/xi_deriv" =>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We used d*dpsi for derivatives in KineticForces. Fortran used *1 and we might have a few uses of that laying around too. We should again standardize throughout for a consistent h5 experience. I think d*dpsi is the least ambiguous (note simple *_psi is not available since we use that for the psi component of things). Again, standardization means changing everything now and adding a note to the h5 conventions md

"ForceFreeStates/Solutions/GalerkinIntegration/Solution/cut_range" =>
(; long_name="ψ_N bounds of the excised resonant + extension cells per surface", dims=("surface", "bound")),
"SingularSurfaces/GalerkinDeltaPrime/delta" =>
(; long_name="outer-region Δ' matrix (2msing×2msing, side-major [L_s1, R_s1, ...]; RDCON Galerkin)", dims=("surface_side", "surface_side")),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand the ; RDCON Galerkin part of this. Remove?

"SingularSurfaces/GalerkinDeltaPrime/pest3_B" => (; long_name="PEST-3 matching block B' (Galerkin outer region)", dims=("surface", "surface")),
"SingularSurfaces/GalerkinDeltaPrime/pest3_Gamma" => (; long_name="PEST-3 matching block Γ' (Galerkin outer region)", dims=("surface", "surface")),
"SingularSurfaces/GalerkinDeltaPrime/pest3_Delta" => (; long_name="PEST-3 matching block Δ' (Galerkin outer region)", dims=("surface", "surface")),
"SingularSurfaces/GalerkinDeltaPrime/sing_psi" => (; long_name="normalized poloidal flux ψ_N of each rational surface"),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This physical quantity is written multiple places. That is ok (it isn't large) but the name should be consistent throughout. Have claude check for other repeated variables while at it and make sure the names are consistent (again, add a note in the conventions so anyone adding to the h5 later is sure to follow suite).

"SingularSurfaces/GalerkinDeltaPrime/pest3_Gamma" => (; long_name="PEST-3 matching block Γ' (Galerkin outer region)", dims=("surface", "surface")),
"SingularSurfaces/GalerkinDeltaPrime/pest3_Delta" => (; long_name="PEST-3 matching block Δ' (Galerkin outer region)", dims=("surface", "surface")),
"SingularSurfaces/GalerkinDeltaPrime/sing_psi" => (; long_name="normalized poloidal flux ψ_N of each rational surface"),
"SingularSurfaces/GalerkinDeltaPrime/sing_q" => (; long_name="safety factor q = m/n at each rational surface", dims=("surface",)),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another quantity written multiple places. Note that I prefer "rational" to "singular" since we have options to do kinetic and resistive runs where the solutions are not singular on the rationals.

"SingularSurfaces/GalerkinDeltaPrime/pest3_Delta" => (; long_name="PEST-3 matching block Δ' (Galerkin outer region)", dims=("surface", "surface")),
"SingularSurfaces/GalerkinDeltaPrime/sing_psi" => (; long_name="normalized poloidal flux ψ_N of each rational surface"),
"SingularSurfaces/GalerkinDeltaPrime/sing_q" => (; long_name="safety factor q = m/n at each rational surface", dims=("surface",)),
"SingularSurfaces/GalerkinDeltaPrime/sing_m" => (; long_name="resonant poloidal mode number m at each rational surface", dims=("surface",)),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perfect example of mixed use of singular (sing_*) and rational. While I am at it, I will explicitly say the ordering of specifiers should be standardized as well... no good having sing_bloop and blip_sing in the same h5 output file! This goes for all specifiers (no using pest3_this here and that_pest3 somewhere else, for example)

"SingularSurfaces/GalerkinDeltaPrime/sing_q" => (; long_name="safety factor q = m/n at each rational surface", dims=("surface",)),
"SingularSurfaces/GalerkinDeltaPrime/sing_m" => (; long_name="resonant poloidal mode number m at each rational surface", dims=("surface",)),
"SingularSurfaces/GalerkinDeltaPrime/sing_n" => (; long_name="resonant toroidal mode number n at each rational surface", dims=("surface",)),
"SingularSurfaces/GalerkinDeltaPrime/di" => (; long_name="Mercier D_I at each rational surface", dims=("surface",)),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lowercase and all one word is inconsistent with use of D_R elsewhere. This was inherited from bad formatting in lazy old fortran. We should modernize and make names intuitively match the standard literature → D_I here. Claude should spin a subagent to look for any similar opportunities to improve and standardize names.

"SingularSurfaces/GalerkinDeltaPrime/sing_n" => (; long_name="resonant toroidal mode number n at each rational surface", dims=("surface",)),
"SingularSurfaces/GalerkinDeltaPrime/di" => (; long_name="Mercier D_I at each rational surface", dims=("surface",)),
"SingularSurfaces/GalerkinDeltaPrime/alpha" => (; long_name="Frobenius small-solution exponent α at each rational surface", dims=("surface",)),
"SingularSurfaces/GalerkinDeltaPrime/delta_coil" => (; long_name="edge coil-response matrix (edge mode × surface-side; RPEC columns)", dims=("mode", "surface_side"))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the capitalization of this delta consistent with the literature?

logan-nc and others added 2 commits August 14, 2026 22:47
…ames, table-driven scales

Addresses the SLAYER/KF review comments:

- Complex quantities are now stored natively everywhere (never *_real/*_imag
  dataset pairs): KineticForces dTdpsi/T and the EnergyIntegrals record and
  trajectory arrays; Tearing Q_root, dels_db, delta_s, ragged Diagnostics
  flat, Scan Q/Delta; the DpMatrix real/imag subgroup collapses to a single
  complex PerSurface/dp_matrix. Sole sanctioned exception (documented):
  Input/RawInputs/ForcingTerms amplitude pair, which mirrors the external
  ingest format and keeps old snapshots replayable. Conventions doc updated;
  the harness gains a complex-preserving first_N_complex extract and
  diiid_slayer_n1 tracks one complex slayer_Q quantity.
- KineticMatrices matrix_1..6 renamed to the Logan 2015 letters A,B,C,D,E,H
  with identities (A = Wz'Wz etc.) and the energy normalization stated.
- Tearing PerSurface dr_val/dgeo_val renamed to the literature names D_R and
  D_geo (struct fields unchanged).
- dVdpsi is now written per KineticForces method at the quadrature points
  (spline passed from the main pipeline) so dT/dV = dTdpsi/dVdpsi is directly
  available; dTdpsi long_name says so.
- leff long_name carries its formula; overkill parentheticals trimmed.
- Square-matrix dims use distinct row/col axis names ((mode_row, mode_col)
  etc.) since xarray mangles repeated dimension names; documented.
- Dimension scales and attachments are now declared in the annotation tables
  (scale=/attach= entry fields handled by annotate! itself); the per-writer
  imperative make_scale!/attach_scale! helpers collapse to bare annotate!
  calls.
- Galerkin: drop the redundant fine-grid Solution/q (derivable from
  Equilibrium/Profiles); trim a cryptic long_name fragment.
- docs/src/kinetic_forces.md explains the complex-torque convention split
  (T profiles carry 2n, total_energy does not; records keep both halves) and
  gives worked flat+offsets read examples in Julia and h5py.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz
…ls, rational, d*dpsi)

Review-driven naming standardization across all writers (conventions codified
in docs/development/hdf5-conventions.md):

- Literature capitalization: di/dr/di0 -> D_I, D_R; the full Delta-prime
  family (delta_prime* -> Delta_prime*, delta -> Delta_prime_raw since it is
  the same side-major object, delta_coil -> Delta_coil, deltar -> Delta_r,
  Tearing dp_matrix -> Delta_prime_matrix, delta_n -> Delta_prime_norm);
  tau_r/taur collision unified as tau_R with tau_A/tau_k following; dc_tmp ->
  D_c_offset, dc_type -> D_c_type.
- One rational-surface convention, specifier-first: sing_*/bare psi,q,m,n ->
  rational_psi/rational_q/rational_m/rational_n everywhere (SingularSurfaces,
  GalerkinDeltaPrime, SingularCoupling incl. rational_m_res -> rational_m);
  msing/kmsing -> rational_count; issing -> is_rational; ising ->
  rational_index.
- Derivatives are d<x>dpsi: q1 -> dqdpsi, q1lim -> dqdpsi_lim, dxi_psi ->
  dxi_psidpsi, xi_deriv -> dxidpsi, clebsch_psi1 -> dclebsch_psidpsi;
  p_local/p1_local/v1_local -> mu0p/dmu0pdpsi/dVdpsi (same-quantity leaf
  names as Profiles); GGJ v1 -> dVdpsi.
- Misnomer cleanup: Roots/omega_Hz -> omega, gamma_Hz -> gamma (values are
  rad/s and 1/s); dels_db -> delta_s_over_d_beta; delta_s_m -> delta_s_abs.
- Struct fields keep their legacy spellings; only HDF5 leaves change. All
  readers, tests, benchmarks, and harness case TOMLs updated in lockstep
  (quantity names preserved).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz
@logan-nc

Copy link
Copy Markdown
Collaborator Author

Both review rounds addressed across two commits (ca870696..279e7b57), propagated to #368. Point-by-point:

KF/SLAYER round:

  • 2n in total_energy — you were right; fixed in ca870696 (scalar stores δW_k = Im(T)/(2n); the ψ-profiles are raw Im(T) and both long_names now state the asymmetry).
  • Native complex storage — done everywhere: KF dTdpsi/T/EnergyIntegrals arrays, Tearing Q_root/dels_db→delta_s_over_d_beta/delta_s/ragged flat/Scan Q,Delta, and the DpMatrix subgroup collapsed to one complex Delta_prime_matrix. Convention codified ("never *_real/*_imag pairs"); sole documented exception is the Input/RawInputs/ForcingTerms amplitude pair (external ingest-format mirror; converting breaks replay of existing snapshots). Harness got a complex-preserving first_3_complex extract for the tracked Q.
  • dT/dVdVdpsi is now written per method at the quadrature points (spline passed from the pipeline), so dTdpsi ./ dVdpsi gives the density directly; also in Equilibrium/Profiles/dVdpsi on the profile grid.
  • matrix_1..6 — renamed to the Logan letters A,B,C,D,E,H with identities (A = W_Z†W_Z, …) and the energy normalization stated (verified: energy_factor = tnorm/(2in) in Torque.jl).
  • Repeated dims — netCDF/CF permits them but xarray mangles them on load; industry practice is distinct names, so all square matrices now use (mode_row, mode_col)-style axes (documented).
  • Scale helpers — folded into the tables: entries now carry optional scale=/attach= fields handled by annotate! itself; the per-writer imperative helpers are gone.
  • leff — long_name carries ℓ_eff = ℓ + n·q (circulating) / ℓ (trapped).
  • Ragged layout — kept (VLEN types have patchy cross-language support; Tearing/Diagnostics uses the same pattern), and docs/src/kinetic_forces.md now explains the complex-torque convention split and gives worked Julia/h5py offset-indexing examples.
  • dr_val/dgeo_val — renamed D_R/D_geo (both are D's in GGJ/CHH usage — checked; @d-burg feel free to counter-propose).

Galerkin round (naming standardization, 279e7b57):

  • Fine-grid Solution/q dropped (redundant with Equilibrium/Profiles).
  • One rational-surface convention, specifier-first: rational_psi/rational_q/rational_m/rational_n everywhere (SingularSurfaces bare names, GDP sing_*, and PE rational_m_res all converge); msing/kmsingrational_count; issingis_rational; Tearing isingrational_index.
  • Literature caps: di/dr/di0D_I/D_R; full Delta family — Delta_prime, Delta_prime_matrix, Delta_prime_raw (GDP delta merges into this leaf — it is the same side-major object), Delta_coil, Delta_r, C_Delta_prime, Delta_prime_norm; tau_r/taur collision → tau_R with tau_A/tau_k following; dc_tmp/dc_typeD_c_offset/D_c_type; omega_Hz/gamma_Hzomega/gamma (the Hz misnomer dies).
  • Derivatives are d<x>dpsi: q1dqdpsi, q1limdqdpsi_lim, dxi_psidxi_psidpsi, xi_derivdxidpsi, clebsch_psi1dclebsch_psidpsi, p1_local/v1_localdmu0pdpsi/dVdpsi (+ p_localmu0p, same-leaf-as-Profiles rule).
  • All five principles are now codified in docs/development/hdf5-conventions.md; readers/tests/benchmarks/harness cases updated in lockstep (quantity names preserved).
  • "; RDCON Galerkin" — removed.

On superscripts for contravariant components: there is no HDF5/netCDF standard for marking super vs subscripts in names — the _ convention is universal (inherited exactly as you guessed), and CF handles typography via attributes, not names. Our long_names already carry the typeset form ("contravariant radial displacement ξ^ψ"); if you want it machine-readable we could add an optional symbol attribute (e.g. "\xi^\psi") to the metadata contract as a follow-up — cheap in the annotate! mechanism, ~340 entries of content.

Tests green at each step (schema 14/14 + 6/6 incl. metadata walk with the renamed tables, SLAYER 64/64, fullruns 17/17). Ready for your re-review — and per policy, your review is the merge gate tonight.

🤖 Generated with Claude Code

https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz

@logan-nc logan-nc left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final human comments done!

Comment thread src/PerturbedEquilibrium/Utils.jl Outdated
"ResponseMatrices/reluctance" => (; long_name="reluctance ϱ̃ in root-area-weighted field space", dims=("mode", "mode")),
"ResponseMatrices/rootarea_to_area_weight_operator" => (; long_name="operator S = Σ/√A at ψ_lim; b̄ = S·b̃", dims=("mode", "mode")),
"ResponseMatrices/surface_area" => (; long_name="control-surface scalar area A = ∮J|∇ψ|dθ; Φ = A·b̄", units="m^2"),
"Response/psi_n" => (; long_name="normalized poloidal flux ψ_N grid shared by the response profiles"),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets make sure the radial variable of normalized poloidal flux is consistently named throughout. It is psi_n here, but I think it might be just psi in ForceFreeStates → standardize throughout the h5

Comment thread src/PerturbedEquilibrium/Utils.jl Outdated
"ResponseMatrices/rootarea_to_area_weight_operator" => (; long_name="operator S = Σ/√A at ψ_lim; b̄ = S·b̃", dims=("mode", "mode")),
"ResponseMatrices/surface_area" => (; long_name="control-surface scalar area A = ∮J|∇ψ|dθ; Φ = A·b̄", units="m^2"),
"Response/psi_n" => (; long_name="normalized poloidal flux ψ_N grid shared by the response profiles"),
"Response/xi_psi" => (; long_name="contravariant radial displacement ξ^ψ = ξ·∇ψ_N", dims=("psi", "mode")),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider if there is a h5 industry standard for subscripts - the use of _ here and elsewhere for contravariant components is potentially confusing.

Comment thread src/PerturbedEquilibrium/Utils.jl Outdated
"ResponseMatrices/surface_area" => (; long_name="control-surface scalar area A = ∮J|∇ψ|dθ; Φ = A·b̄", units="m^2"),
"Response/psi_n" => (; long_name="normalized poloidal flux ψ_N grid shared by the response profiles"),
"Response/xi_psi" => (; long_name="contravariant radial displacement ξ^ψ = ξ·∇ψ_N", dims=("psi", "mode")),
"Response/xi_psi_J" => (; long_name="Jacobian-weighted contravariant radial displacement J·ξ^ψ", units="m^3", dims=("psi", "mode")),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable name reads as though J is a subscript. Other variables I believe put the J in front (like Jbgradpsi). Standardize throughout.

Comment thread src/PerturbedEquilibrium/Utils.jl Outdated
"Response/psi_n" => (; long_name="normalized poloidal flux ψ_N grid shared by the response profiles"),
"Response/xi_psi" => (; long_name="contravariant radial displacement ξ^ψ = ξ·∇ψ_N", dims=("psi", "mode")),
"Response/xi_psi_J" => (; long_name="Jacobian-weighted contravariant radial displacement J·ξ^ψ", units="m^3", dims=("psi", "mode")),
"Response/xi_theta" => (; long_name="Jacobian-weighted contravariant poloidal displacement J·ξ^θ", units="m^3", dims=("psi", "mode")),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it confusing to have the J explicitly in the psi component but implicit here and in zeta?

Comment thread src/PerturbedEquilibrium/Utils.jl Outdated
"Response/xi_zeta" => (; long_name="Jacobian-weighted contravariant toroidal displacement J·ξ^ζ", units="m^3", dims=("psi", "mode")),
"Response/xi_theta_reg" => (; long_name="regularized Jacobian-weighted contravariant poloidal displacement J·ξ^θ", units="m^3", dims=("psi", "mode")),
"Response/xi_zeta_reg" => (; long_name="regularized Jacobian-weighted contravariant toroidal displacement J·ξ^ζ", units="m^3", dims=("psi", "mode")),
"Response/xi_cova_psi" => (; long_name="covariant radial displacement ξ_ψ", units="m^2", dims=("psi", "mode")),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we find a better standard for distinguishing superscripts and subscripts, we can get rid of this clumsy _cova_ notation

Comment thread src/PerturbedEquilibrium/Utils.jl Outdated
"Response/xi_phi" => (; long_name="cylindrical displacement component ξ_φ (mode space)", units="m", dims=("psi", "mode")),
"Response/b_psi_area_weighted" => (; long_name="area-normalized radial field b^ψ/⟨J|∇ψ|⟩_θ", units="T", dims=("psi", "mode")),
"Response/b_n" => (; long_name="physical normal field b_n", units="T", dims=("psi", "mode")),
"Response/b_theta" => (; long_name="Jacobian-weighted contravariant poloidal field J·b^θ", units="T*m^2", dims=("psi", "mode")),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought there was a big push to make all the weights of the fields be Tesla... was it just the psi components then? This deserves a second hard look from a devoted physics agent review.

Comment thread src/PerturbedEquilibrium/Utils.jl Outdated
"Response/b_zeta" => (; long_name="Jacobian-weighted contravariant toroidal field J·b^ζ", units="T*m^2", dims=("psi", "mode")),
"Response/b_theta_reg" => (; long_name="regularized Jacobian-weighted contravariant poloidal field J·b^θ", units="T*m^2", dims=("psi", "mode")),
"Response/b_zeta_reg" => (; long_name="regularized Jacobian-weighted contravariant toroidal field J·b^ζ", units="T*m^2", dims=("psi", "mode")),
"Response/b_cova_psi" => (; long_name="covariant radial field b_ψ", units="T*m", dims=("psi", "mode")),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also spin a devoted agent review of these cova units

Comment thread src/PerturbedEquilibrium/Utils.jl Outdated
"SingularCoupling/C_resonant_current" => (; long_name="coupling matrix: applied b̃ → pitch-resonant current", units="A/T", dims=("surface", "mode")),
"SingularCoupling/C_island_width_sq" => (; long_name="coupling matrix: applied b̃ → squared island half-width", units="1/T", dims=("surface", "mode")),
"SingularCoupling/C_penetrated_area_weighted_field" => (; long_name="coupling matrix: applied b̃ → penetrated area-weighted field", dims=("surface", "mode")),
"SingularCoupling/C_delta_prime" => (; long_name="coupling matrix: applied b̃ → forcing-driven Δ'", units="1/T", dims=("surface", "mode")),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we are conforming all Δ → Delta and reserving any delta for  δ type variables. This should conform as well.

Comment thread src/PerturbedEquilibrium/Utils.jl Outdated
"Response/b_R" => (; long_name="cylindrical field component b_R (mode space)", units="T", dims=("psi", "mode")),
"Response/b_Z" => (; long_name="cylindrical field component b_Z (mode space)", units="T", dims=("psi", "mode")),
"Response/b_phi" => (; long_name="cylindrical field component b_φ (mode space)", units="T", dims=("psi", "mode")),
"SingularCoupling/C_resonant_area_weighted_field" => (; long_name="coupling matrix: applied b̃ → resonant area-weighted field b̄^r = Φ^r/A^r", dims=("surface", "mode")),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does "surface" work as a dimension? If it's the rational surface index, this deserves a second look. Any physicist would want things to natively plot vs rational q or rational psi. That being said, perhaps the index is necessary to properly prepare for the future expansion to full 3D with multiple n such that there can be multiple resonances per rational q... if that is the case, then ok. But perhaps add documentation somewhere of how to quickly plot things like the effective resonant field vs q

Comment thread src/PerturbedEquilibrium/Utils.jl Outdated
"SingularCoupling/resonant_current" => (; long_name="pitch-resonant current per rational surface", units="A", dims=("surface",)),
"SingularCoupling/island_width_sq" => (; long_name="squared island half-width per rational surface (in ψ_N²)", dims=("surface",)),
"SingularCoupling/penetrated_area_weighted_field" => (; long_name="penetrated area-weighted field per rational surface", units="T", dims=("surface",)),
"SingularCoupling/delta_prime" => (; long_name="forcing-driven tearing Δ' per rational surface (Riccati; response to applied forcing)", dims=("surface",)),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be Delta_prime

…dit fixes

Final review round (PE comments) plus an independent whole-schema
standardization audit and a devoted physics review of the PE units.

Naming:
- One coordinate vocabulary: the radial abscissa is `psi` and the poloidal one
  `theta` in every group (Response/psi_n, Profiles/xs, Geometry/xs,ys are gone).
- Vector components follow one scheme: bare suffix = contravariant, `_cov_` =
  covariant, leading `J` = Jacobian-weighted (Jxi_psi, Jxi_theta, Jb_zeta,
  xi_cov_psi, ...). There is no HDF5/netCDF super/subscript standard, so the
  typeset form stays in long_name; both rules are documented.
- One leaf per quantity across integrator backends: Galerkin Solution xi ->
  xi_psi, dxidpsi -> dxi_psidpsi, xi_cut -> xi_psi_cut; Match InnerParams
  taua/taur/v1 -> tau_A/tau_R/dVdpsi (matching the Tearing mapping);
  KineticForces nn -> n; dims axis mode_index -> mode.

Bug fixes found by the audit:
- LocalStability had a name collision hiding a missing coordinate: D_I, D_R and
  ballooning_Delta_prime live on the locstab grid, which was never written,
  while LocalStability/psi was actually the unrelated ballooning alpha-scan
  grid. The profile grid is now written as psi (a scale, attached to all three)
  and the alpha scan as ballooning_psi with its own scale.
- Tearing PerSurface/tau_R had two table entries with conflicting long_names;
  annotate! is last-wins, so every SLAYER run was labelled "(GGJ layer
  parameters)". Merged into one neutral entry (the layer_model group attribute
  already disambiguates the branch).

Metadata completeness: rational_q is now a dimension scale in all three
rational-surface groups (plot vs psi or q natively); SingularSurfaces/Kinetic
datasets gained dims/scale/attach; resonant_area_weighted_field and
dclebsch_psidpsi gained their missing attaches; dead b_norm and unreachable
Kinetic ELM entries trimmed.

Physics review verdict (no unit changes): the tesla convention covers the
normal-field representations only - Jb_theta/Jb_zeta are flux-like densities
(T*m^2) and converting them would falsely imply invariance; the covariant units
are confirmed by the metric construction rather than dimensional analysis.
long_names now state both so they are not "fixed" later.

Remaining non-blocking recommendations tracked in #388.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz
@logan-nc

Copy link
Copy Markdown
Collaborator Author

Final round pushed (279e7b57..d668f572). All 31 review comments are now addressed; follow-ups that were deliberately left out are tracked in #388.

PE round (Q1–Q11)

  • Coordinate naming — one vocabulary now: the radial abscissa is psi and the poloidal one theta in every group. Response/psi_n, Profiles/xs, Geometry/xs/ys are all gone; documented as a rule.
  • Super/subscripts — there is no HDF5 or netCDF standard for them (you guessed right: the flat _ convention is inherited from the Fortran netCDF era and is what every code uses). CF handles typography in attributes, not names. So: bare suffix = contravariant (xi_psi = ξ^ψ), _cov_ = covariant (b_cov_theta = b_θ), leading J = Jacobian-weighted (Jxi_psi, Jb_zeta) — J moved to the front exactly as you noted Jbgradpsi does, and the implicitly-weighted xi_theta/b_zeta/etc. are now explicitly Jxi_theta/Jb_zeta so nothing is implicit any more. The typeset form always appears in long_name. If you later want it machine-readable, an optional symbol attribute would slot into the annotate! mechanism cheaply.
  • 1 derivatives — already gone in 279e7b57 (clebsch_psi1dclebsch_psidpsi, q1dqdpsi, q1limdqdpsi_lim, xi_derivdxi_psidpsi).
  • Δ vs δ — conformed in 279e7b57; Delta_prime at the line you flagged, with the whole Delta family capitalized and δ-quantities (delta_s, d_beta) left lowercase.
  • Tesla weights (devoted physics review, as requested)verdict: current units are correct, no change. The tesla convention covers the normal-field representations (b, b̃, b̄, b_n, resonant/penetrated). Jb_theta/Jb_zeta are flux-like contravariant densities (T*m^2), coordinate-dependent by construction with no scalar area to make them invariant — rendering them "in tesla" would be a false promise. Worth knowing: the ψ component already is one of the tesla three — b_psi_area_weighted = (J·b^ψ)ₘ/A is exactly , since J|∇ψ|·b_n = J·b^ψ pointwise. Its long_name now says so. Tesla-valued tangential quantities already exist as b_R/b_Z/b_phi.
  • Covariant units (second devoted review)confirmed, and now proven rather than dimensionally argued: metric.fs[1..6] are g_ij/J (I verified independently — v[3,3] = 2πR/jac is |e_ζ|/J, not ∇ζ), so the contraction gives ξ_i = ξ·e_i → m², and b_i → T·m. long_names now spell out the ξ·e_ψ form so the units are self-evident.
  • surface as a dimension — your instinct was right that a physicist wants to plot vs q. The index stays (multi-n can put several resonances on one q, as you supposed), but rational_q is now a second dimension scale attached to that axis alongside rational_psi, in all three rational-surface groups — so plotting vs either is native in h5py/xarray. docs/src/perturbed_equilibrium.md has a worked snippet.

Independent standardization audit (your closing ask)

Two blocking finds, both real and fixed here:

  1. LocalStability/ had a name collision hiding a missing coordinate. D_I, D_R and ballooning_Delta_prime are computed on the locstab grid — which was never written to the file — while LocalStability/psi was actually the unrelated ballooning α-scan grid. Nobody could plot D_I against anything. The profile grid is now written as psi (a scale, attached to all three) and the α scan as ballooning_psi with its own scale.
  2. PerSurface/tau_R had two table entries with conflicting long_names after my τ unification. annotate! is last-wins, so every SLAYER run was being labelled "(GGJ layer parameters)". Merged into one neutral entry; the layer_model group attribute already tells you which branch ran.

Also fixed: SingularSurfaces/Kinetic/* had visibly thinner metadata than its sibling (no dims/scale/attach — now wired); resonant_area_weighted_field and dclebsch_psidpsi were each missing an attach their siblings had; dead b_norm and four unreachable Kinetic ELM table entries trimmed; Match/InnerParams taua/taur/v1tau_A/tau_R/dVdpsi (the project already had that exact mapping in Tearing); nnn; mode_indexmode; and the conventions doc itself was violating two of its own rules in prose.

Audit also confirmed clean: units-string formatting is uniform across all five tables, scale names agree across all seven ψ-grid producers, no sing/psi_n/xs/_val/_tmp/*_real survivors, and no writer↔table drift.

Verification

Schema+metadata test 14/14 (+6/6 rule tests), SLAYER runner 64/64, fullruns, coils, rerun-from-h5 all green; package loads; margins/whitespace clean. The schema test earned its keep twice tonight — it caught an interpolated $fwd/dxi_psi writer/table mismatch and then my own _CachedVector write bug in the LocalStability fix.

🤖 Generated with Claude Code

https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz

Closes the one review item left half-addressed: is_rational was renamed but
the standard behind it was never written down. States that multi-word dataset
names are snake_case English (CamelCase is groups-only) and that flags carry
an is_ prefix only where the bare word would read as a noun or collide with a
data family - which is why is_rational has one and enabled/truncated/no_root
do not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz
@logan-nc

Copy link
Copy Markdown
Collaborator Author

Audit correction (0959bcc9): re-checking my own work against all 31 comments, one was only half-addressed. On the issing comment you asked two things — rename it and "we should standardize throughout either way" with "a documented standard for variable name syntax in cases like this where the variables are words". I did the rename (is_rational) but never wrote the standard down, and the file was in fact inconsistent: is_rational alongside bare enabled, truncated, no_root.

Now documented in the conventions md: multi-word dataset names are snake_case English (CamelCase is groups-only), and a boolean flag takes an is_ prefix only where the bare word would read as a noun or collide with a data family — which is exactly why is_rational needs one (bare rational would clash with the rational_* coordinate family) while enabled/truncated/no_root already read as predicates and stay as they are. That legitimizes the existing names under one stated rule rather than forcing three more renames at merge time.

Also verified in the same pass, for the record: Delta_coil is capitalized (your "is the capitalization of this delta consistent with the literature?"); the only lowercase delta datasets left are delta_s/delta_s_abs/delta_s_over_d_beta, which are genuinely δ_s in Fitzpatrick; and the only trailing-1 names left are betap1/delta1/li1, which are definition indices, not derivatives.

logan-nc and others added 3 commits August 15, 2026 08:16
…sch_*)

The three Clebsch datasets were the only Response/ entries that omitted the
variable they represent: clebsch_psi / clebsch_alpha / dclebsch_psidpsi say the
representation and the coordinate but never that it is a xi displacement.
Renamed to xi_clebsch_psi / xi_clebsch_alpha / dxi_clebsch_psidpsi, matching
xi_cov_psi and the rest of the Response group.

The component rule in the conventions doc now states the full pattern —
[d]<variable>[_<representation>]_<coordinate>[dpsi], variable always first,
coordinate always the trailing subscript — so this class of omission is a
documented error rather than a judgement call. A sweep of every component-
shaped leaf name confirms these three were the only instances.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129rSTCmYJDBbcH9khHqYnz
Rename the Equilibrium/ scalar datasets from the Fortran-era contractions to
spelled-out physics names with trailing qualifier subscripts: q0/qa/q95 ->
q_axis/q_edge/q_95, bt0/bwall -> B_T_axis/B_T_wall, ro/zo/b0 ->
R_axis/Z_axis/B_axis, amean/rmean/aratio -> a_mean/R_mean/aspect_ratio,
crnt -> I_p, delta1/delta2 -> delta_upper/delta_lower, betat/betan/betap*/betaj
-> beta_t/beta_N/beta_p_*/beta_j, li1-3 -> l_i_1-3, psio -> psi_total,
mextrema -> q_extrema_count, and the boundary shape arrays to R_midplane
(inboard/outboard midplane crossings) and R_extremum/Z_extremum (Z extrema).

EquilibriumParameters keeps its field spellings; EQUIL_H5_NAMES maps fields to
dataset names and EQUIL_H5_SKIP drops the four exact duplicates of psi_total /
psi_norm (psi0, psi_axis, psi_axis_norm, zsep) and the three control-flag echoes
(verbose, diagnose_src, diagnose_maxima), which belong under Input/ only.

Readers, benchmark and LAR scan scripts, the harness runner and case h5paths
follow; harness quantity names are unchanged so cached history still matches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGmFuAw5JdYrAyBSXCVssR
@logan-nc

Copy link
Copy Markdown
Collaborator Author

Equilibrium/ scalar naming pass (final standardization item)

The last group still carrying Fortran-era contractions was Equilibrium/ itself. Pushed as H5 - IMPROVEMENT - Give Equilibrium scalars literature names, applying the same rule the rest of the file now follows: variable first, qualifier as a trailing subscript, numbered literature definitions keep their number as the last subscript.

Family Before → after
Axis / wall ro/zo/b0/bt0/bwallR_axis/Z_axis/B_axis/B_T_axis/B_T_wall
Geometry rmean/amean/aratioR_mean/a_mean/aspect_ratio; delta1/delta2delta_upper/delta_lower
Boundary shape rsepR_midplane, rext/zextR_extremum/Z_extremum
Safety factor q0/qa/q95/qmin/qmaxq_axis/q_edge/q_95/q_min/q_max; mextremaq_extrema_count
Beta / inductance betat/betan/betaj/betap1-3beta_t/beta_N/beta_j/beta_p_1-3; li1-3l_i_1-3
Other crntI_p, bt_signB_T_sign, psiopsi_total

Descriptive _axis was chosen over literature _0 so the qualifier slot reads identically across _axis / _edge / _wall / _min / _upper, and so it matches the psi_axis / psi_boundary pair already in the file.

On rsep vs rext: the docstrings ("plasma boundary" / "plasma edge") were not distinguishing them. rsep solves θ + ν(θ) − ν₀ = 0 at ν₀ = 0 and 0.5, i.e. the inboard and outboard midplane crossings; rext/zext are at the Z extrema. zsep is assigned the identical value as zext one line apart, so it is dropped.

Seven datasets leave the file: four exact duplicates (psi0, psi_axis, psi_axis_norm, zsep — the first three are all the same number as psi_total, with psi_axis additionally mislabeled) and three control-flag echoes (verbose, diagnose_src, diagnose_maxima) that violate the "inputs live only under Input/" rule this PR codified.

EquilibriumParameters is untouched. A EQUIL_H5_NAMES map plus an EQUIL_H5_SKIP set in src/HDF5Schema.jl sit between the struct fields and the dataset names — the same indirection Tearing/Runner/HDF5Output.jl already uses. Readers (Analysis/{Equilibrium,ForceFreeStates,PerturbedEquilibrium,PerturbedEquilibriumModes}.jl), the Fortran-comparison benchmark, both LAR scan scripts, regression-harness/src/runner.jl, and 13 harness case h5paths follow. Harness quantity names are unchanged, so cached history still matches.

Follow-up filed as #389: while auditing the assignment sites I found that most of the flux bookkeeping (psi_boundary, the offsets, the signs, psi_boundary_zero) is hardcoded literals rather than computed from the ingest, and that ro/zo/psio/b_norm are never assigned on the struct at all. Per review direction those stay in the output for now and are handled there, not here.

Docs updated (docs/development/hdf5-conventions.md naming rules + schema table, docs/src/workflow.md). Schema, rerun-from-h5, and fullruns tests running now; I will report results.

Important

This PR still requires a third-party human review approval at its current head before merge. The branch has moved several commits past the earlier approval, and with dismiss_stale_reviews: false / require_last_push_approval: false on this repo an auto-merge label would merge on that stale approval. Not applying it.

@logan-nc
logan-nc enabled auto-merge August 15, 2026 13:07
Equilibrium/psi_boundary is assigned a hardcoded 1.0, so labelling it as the
boundary poloidal flux in Wb/rad was actively wrong. Say what it is - the
boundary value in the internal normalized convention, 1 by construction - and
drop the Wb/rad unit so it annotates as dimensionless. Same treatment for
psi_boundary_norm, which is tautological in psi_N.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VGmFuAw5JdYrAyBSXCVssR
@logan-nc

Copy link
Copy Markdown
Collaborator Author

Correction to my previous comment: the separatrix bracket solves θ + η(θ) − η₀ = 0 (the poloidal-angle offset rzphi_offset, written as Geometry/offset), not ν — ν is the toroidal offset φ − 2πζ in Geometry/nu. The conclusion is unchanged: rsep is the inboard/outboard midplane crossing, hence R_midplane.

Also pushed H5 - IMPROVEMENT - Make the psi_boundary long_names honest: Equilibrium/psi_boundary is assigned a hardcoded 1.0, so advertising it as the boundary poloidal flux in Wb/rad was flatly wrong. It now annotates as the boundary value in the internal normalized convention (1 by construction) and carries no Wb/rad unit; psi_boundary_norm says it is 1 by definition of ψ_N. Recomputing them properly is #389.

Stack propagated: #368 (bugfix/366-ca-uninitialized) and #385 (feature/h5-runtime-records) both have this head merged in and pushed. #368 had one conflict in src/HDF5Schema.jl, resolved to keep both improvements — its zero-extent ca_left/ca_right long_names and this branch's dual rational_psi+rational_q dimension scales on the GGJ coefficient rows.

Local runtests_h5_schema.jl: 14/14 pass. runtests_rerun_from_h5.jl + runtests_fullruns.jl still running; will report.

@logan-nc
logan-nc merged commit b3abe07 into develop Aug 15, 2026
6 checks passed
@logan-nc
logan-nc deleted the refactor/hdf5-metadata branch August 15, 2026 13:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

refactor Behavior-preserving restructuring

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants