Skip to content

feat(antarctica): ISMIP7 core-experiment pipeline with n=3 rheology and hist-branched CTRL - #4

Closed
hoffmaao wants to merge 74 commits into
icepack:mainfrom
hoffmaao:antarctica-n3
Closed

feat(antarctica): ISMIP7 core-experiment pipeline with n=3 rheology and hist-branched CTRL#4
hoffmaao wants to merge 74 commits into
icepack:mainfrom
hoffmaao:antarctica-n3

Conversation

@hoffmaao

@hoffmaao hoffmaao commented Jul 26, 2026

Copy link
Copy Markdown
Member

Intent

Correct the rationale for the hist-branch CTRL change (comment + operator-warning text only; no behavior change). Context: an earlier commit (01696a7, already on PR #4) justified branching the CTRL from the historical endpoint by claiming the CTRL/projection apparent-MB (a_ref) mismatch of -95 vs -777 Gt/yr was a constant net sink that leaked a spurious ~160 mm SLE trend into proj-CTRL, and that removing it dropped us into the ISMIP6 envelope. That reasoning was WRONG: a_ref is a t=0 balancing correction that zeroes the initial thickness tendency, not a free net sink, so multiplying 681 Gt/yr by 85 years estimates nothing physical. An isolation test at fixed n=4 rheology (same ssp126 projection, only the control baseline swapped) shows the cold-start CTRL (its own drift +8 mm) gives proj-CTRL=+132 mm while the hist-branch CTRL (drift +37 mm) gives +161 mm - i.e. the CORRECT same-state control makes the signal LARGER, because the projection's historical-endpoint relaxation only cancels when the control shares it. The hist-branch change is KEPT (it is the protocol-correct same-state differencing the ISMIP6 ctrl_proj convention requires), but the ISMIP6 overshoot is now understood to be a real forced-response bias (melt/dynamics), not a differencing artifact; n=3 rheology roughly halves it (ssp126 +85 mm, still above the [-14,+50] envelope). This commit only rewrites the code comment and the cold-start warning message to state the correct rationale. This lands on antarctica-n3 / PR #4, which stays OPEN (Shapero merges to main later).

What Changed

  • Builds the ISMIP7 Antarctic simulation framework: icepack2_tools gains forcing readers (ISMIP7 atmosphere/ocean, RACMO baseline), regridding, dual friction, and a thermal model; antarctica/scripts adds the shared experiment runner, control/projection drivers for all core experiments (historical, ssp126/370/585 x 2 ESMs, CTRL, OCX), conservative thickness transport with mass-budget audits, rank-robust warm restarts, a solver rescue ladder with per-step dt-subcycling, and gated when-ready launchers with preflight checks.
  • Adds the n=3 rheology track: a physical-prior inversion (thermo-derived fluidity plus a Whittle-Matern prior) with a converged, validated MAP, n-tagged MAP filenames threaded through preflight, launchers, and the forward drivers, and loud failure on non-converged inversion solves. The CTRL is now branched from the historical endpoint (protocol-correct same-state differencing), with the rationale corrected in code comments and docs: the ISMIP6 envelope overshoot is a real forced-response bias, not an a_ref differencing artifact, and n=3 roughly halves it.
  • Records results and tooling for the matrix: 9/11 core experiments complete at 32 km with per-core reports, MATRIX_STATUS.md, an ISMIP6 ensemble comparison script with scenario-aware pool selection, an ISMIP6-track audit script, per-basin melt calibration, and a reworked Globus forcing mirror for the reorganized GHub share. The no-mistakes review pass surfaced 30 findings; the two documentation ones (stale hist-branch rationale in README/MATRIX_STATUS and the cores 9/10 cold-start CTRL provenance note) were fixed on-branch, and the remaining code-level findings are tracked as open items.

Risk Assessment

✅ Low: The new delta is a four-line docs-only addition to MATRIX_STATUS.md that implements the approved provenance-note fix verbatim in spirit - factual, no retracted claim reintroduced, no code or behavior change - leaving nothing new to flag.

Testing

Proved the change is behavior-neutral via normalized-AST comparison of run.py before/after, then exercised the real CTRL driver in the Firedrake environment through both branch paths to capture the actual operator-facing messages, confirming the corrected same-state-differencing rationale replaces the retracted a_ref-sink claim in code, warning text, and docs; all checks passed and the worktree was left clean.

Evidence: CTRL operator messages (live transcript, both branch paths)

CASE A: no historical endpoint present -> cold-start WARNING path WARNING: no historical endpoint hist_cesm2_waccm_evharness_2500_final.h5; cold-starting from the inversion. The CTRL will start from a DIFFERENT geometry than the hist-branched projections, so projection-minus-CTRL will not cleanly isolate the forced response. Run the historical first, or pass --restart. CASE B: historical endpoint present -> hist-branch message path Branching CTRL from the historical endpoint (same initial state as the projections; shared drift cancels in proj-CTRL): hist_cesm2_waccm_evharness_2500_final.h5

========================================================================
CASE A: no historical endpoint present -> cold-start WARNING path
========================================================================
  WARNING: no historical endpoint hist_cesm2_waccm_evharness_2500_final.h5; cold-starting from the inversion. The CTRL will start from a DIFFERENT geometry than the hist-branched projections, so projection-minus-CTRL will not cleanly isolate the forced response. Run the historical first, or pass --restart.

========================================================================
CASE B: historical endpoint present -> hist-branch message path
========================================================================
  Branching CTRL from the historical endpoint (same initial state as the projections; shared drift cancels in proj-CTRL): hist_cesm2_waccm_evharness_2500_final.h5

harness done; fake endpoint removed: True
Evidence: Harness used to drive run.py:main() through the branch decision
"""Drive antarctica/scripts/control/run.py:main() through the CTRL-branching
decision for real, stopping just after the operator message (before any mesh
or solver work) by stubbing setup_model."""
import os, sys

WORKTREE = os.environ["WORKTREE"]
sys.path.insert(0, os.path.join(WORKTREE, "antarctica", "scripts", "control"))

import run  # noqa: E402  (imports firedrake + the real module under test)


class StopAfterBranch(Exception):
    pass


def fake_setup_model(*a, **k):
    raise StopAfterBranch


run.setup_model = fake_setup_model

esm_tag = run.ESM.lower().replace("-", "_")
tag = "evharness"
hist = os.path.join(
    run.RESULTS_DIR, f"hist_{esm_tag}_{tag}_{run.lc}_final.h5"
)

print("=" * 72)
print("CASE A: no historical endpoint present -> cold-start WARNING path")
print("=" * 72)
assert not os.path.exists(hist), f"unexpected pre-existing file: {hist}"
sys.argv = ["run.py", "--tag", tag]
try:
    run.main()
except StopAfterBranch:
    pass

print()
print("=" * 72)
print("CASE B: historical endpoint present -> hist-branch message path")
print("=" * 72)
os.makedirs(run.RESULTS_DIR, exist_ok=True)
with open(hist, "wb") as f:
    f.write(b"placeholder")
try:
    sys.argv = ["run.py", "--tag", tag]
    try:
        run.main()
    except StopAfterBranch:
        pass
finally:
    os.remove(hist)
print()
print("harness done; fake endpoint removed:", not os.path.exists(hist))

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

⏭️ **Rebase** - skipped
  • ⚠️ antarctica/scripts/control/run.py - merge conflict rebasing onto origin/main
  • ⚠️ antarctica/scripts/simulation.py - merge conflict rebasing onto origin/main
🔧 **Review** - 30 issues found → auto-fixed (2) ✅
  • 🚨 icepack2_tools/forcing.py:755 - Flotation mask density mismatch: both melt callbacks compute haf = s - (b + (_RHO_WATER/_RHO_ICE)max(-b,0)) with freshwater _RHO_WATER=1000 while the model surface s is built with seawater flotation (rho_water=1024 per icepack2.constants and simulation.py), so genuinely floating ice with h between ~0.866depth and ~1.117*depth is classified grounded and receives zero ocean melt - exactly the near-grounding-line band where melt matters most. Same expression at forcing.py:755 (CTRL climatology callback) and forcing.py:824-826 (projection callback). Ask-user because fixing it changes scientific results and the per-basin K calibration may have partially compensated.
  • 🚨 antarctica/scripts/run_control.py:303 - run_control.py's VAF and total-mass diagnostics multiply by rho_I imported from icepack2.constants (ice_density in icepack2's MPa-m-yr unit system, not 917 kg/m^3), so the reported mm SLE and Gt values are off by many orders of magnitude; simulation.py fixed the identical diagnostics with _RHO_I_SI=917.0 (lines 1299-1300) but this script was not updated.
  • 🚨 antarctica/scripts/simulation.py:357 - u_c is set from a rank-LOCAL mean of observed speed (Function(Q).interpolate(...).dat.data_ro.mean() with no comm reduction, unlike the mesh.comm.allreduce used elsewhere in the file), so under mpiexec each rank builds a different sliding scale in K_base (line 391) and K_linear (line 406) and the assembled momentum operator is partition-dependent - results silently change with rank count, defeating the file's own rank-count-robust restart design. run_control.py:139 has the same rank-local mean.
  • 🚨 antarctica/scripts/inversion_icepack2.py:549 - The inversion's solve-failure sentinel returns last_good_obj[0]*10, but last_good_obj stores the misfit-only J_val (line 556) while successful evaluations return total = J_val + reg_theta + reg_phi; with GAMMA defaults of 1e4 the regularization terms can dominate late in optimization, so a mid-run forward/adjoint failure can return an 'objective' lower than the current total with a zero gradient, which L-BFGS-B accepts and then terminates on projected-gradient 0, silently writing the MAP from the failed control point; the sentinel should store the last good TOTAL (both the forward guard at ~549 and the adjoint guard at ~574).
  • ⚠️ icepack2_tools/forcing.py:37 - smb_kgm2s_to_myr multiplies by a spurious _RHO_WATER/_RHO_ICE factor (kg/m^2/s * sec/yr / rho_ice already IS m/yr ice equivalent), inflating every ISMIP7 acabf SMB anomaly by ~9%, inconsistent with the RACMO loader which divides only by rho_ice; already tracked as open item 1 in MATRIX_STATUS.md as 'a real pending decision', so it needs the author's call.
  • ⚠️ antarctica/scripts/run_control.py:1 - run_control.py is a stale duplicate of the simulation.py pipeline that is scientifically inconsistent with the branch: it loads the untagged (legacy n=4) MAP but applies n=3 Glen, uses the non-conservative legacy transport with mass-injecting h_clamp=10 L2 projection, truncates nsteps with int(), and writes non-restartable checkpoints without t_yr; it is superseded by control/run.py and should be deleted or reduced to a shim before someone runs it as 'the control'.
  • ⚠️ antarctica/scripts/simulation.py:1004 - Checkpoints record friction and lc attrs (and restarts hard-fail on friction/a_ref mismatch) but not n_flow/a4_factor/m_slide, so resuming a checkpoint under a different ISMIP7_N_FLOW or ISMIP7_A4_FACTOR (e.g. resuming an n=4 antarctica-branch run on this n=3 branch, whose defaults differ) runs cleanly with silently wrong rheology - the same failure class the friction guard at line 284 was added to prevent.
  • ⚠️ antarctica/scripts/inversion_icepack2.py:658 - If the last scipy evaluation ended in a caught ConvergenceError, n_flow/m_slide are left at mid-continuation values (e.g. n=1.5), and the final slvr.solve() can converge at the wrong exponent without raising, so the 'Final misfit (masked)' and the velocity field written into the MAP checkpoint use the wrong rheology; reassign n_flow/m_slide to their target values before the final solve.
  • ⚠️ antarctica/scripts/launch_rc_ctrl_when_ready.sh:99 - Presence-vs-value env-var mismatch: launching with ISMIP7_FIXED_FRONT=0 (intending fixed-front OFF) still runs with fixed front because the script only skips its own export while the inherited variable remains set and simulation.py:828 tests presence (is not None); the non-1 branch needs unset ISMIP7_FIXED_FRONT. launch_rc_inversion_when_ready.sh:89 has the same bug with ISMIP7_NO_CALVING_TERMINUS=0 silently dropping the calving-terminus term (inversion_icepack2.py:155 tests presence).
  • ⚠️ antarctica/scripts/experiment.py:121 - The pre-run atmosphere check only verifies that SOME acabf years exist, not that they cover [t_start, t_end], and ISMIP7Atmosphere.get_field silently returns zeros for a missing year; in full-field fallback mode (smb_anomaly=False, taken whenever RACMO is absent) any coverage gap silently applies zero total SMB for the missing years, corrupting the mass balance with no warning. Ask-user because a zero-anomaly extension is partly intentional (the tracked 2014-to-2015 handoff gap relies on it), so the right fix is a coverage check against the run window rather than removing the zero fallback.
  • ⚠️ antarctica/scripts/experiment.py:113 - Projections have no auto-resume path (ISMIP7_AUTO_RESUME is only wired into control/run.py): rerunning a crashed multi-week projection without a manual ISMIP7_RESTART restarts from hist_*_final.h5 (t=2014), and run_simulation's CSV truncation (simulation.py:1041-1053) then deletes every timeseries row after 2014.5, silently discarding all of the crashed run's progress; wiring latest_checkpoint into experiment.py would mirror the control driver.
  • ⚠️ icepack2_tools/regrid.py:56 - ISMIP7Regridder computes its query-point bounding box from rank-local coordinates.dat.data_ro, but Firedrake VertexOnlyMesh defaults to redundant=True (points from rank 0 only), so under MPI the output grid is silently cropped to rank 0's partition bbox and the per-rank _query_shape/crop.reshape mismatches on other ranks - parallel regridded NetCDF output is wrong or crashes.
  • ⚠️ icepack2_tools/forcing.py:288 - ISMIP7Atmosphere.get_field silently returns zeros when a year's file is missing (_load_year returns None for any missing or misnamed path), and get_thermal_forcing has the same silent-zero fallback for a missing variable directory, so a renamed file or a run past the last available year quietly zeroes the forcing mid-run instead of failing; at minimum this should warn loudly once per missing year.
  • ⚠️ antarctica/scripts/core_report.py:56 - core_report.py always passes its --exps default "exp01..exp05" to compare_ismip6.py, so the scenario-aware auto-pool selection added in commit 01696a7 can never engage via core_report: an ssp126 report is graded against a mostly-RCP8.5 ensemble pool (only exp03 is RCP2.6) and the tracked 'outside envelope' verdicts (core05/06/08 reports) use the wrong comparison set; the default should be None so auto-selection runs, but this changes graded verdicts in tracked reports.
  • ⚠️ antarctica/scripts/core_report.py:87 - core_report.py conflates tool failure with a scientific result: any nonzero exit from compare_ismip6.py is written to the tracked report as 'outside envelope', but exit code 2 means usage/data error (missing ~/data/ismip6 archive, no year overlap), and audit rc==2 is similarly recorded as 'OFF TRACK' when the CSV merely lacks budget columns - a broken comparison gets permanently recorded as an out-of-envelope finding.
  • ⚠️ antarctica/scripts/download_forcing.py:313 - download_forcing.py skip-if-exists uses only local_path.exists() with no size/checksum comparison, so a file truncated by an interrupted transfer prints '[exists]', is excluded from the TransferData list, and the sync_level="checksum" repair never runs - the corrupt file silently feeds the pipeline indefinitely.
  • ⚠️ antarctica/scripts/download_forcing.py:406 - download_forcing.py scenario-group completeness is a bare file-count test (n_local < n_remote), so truncated local files or stale extras make the count match, the group is marked complete and skipped, and the checksum-sync transfer that would repair corruption is never submitted.
  • ⚠️ antarctica/scripts/download_forcing.py:394 - download_forcing.py treats a transient Globus auth/network error the same as absent data: list_remote_files returning [] makes _pick_version return None, reported as 'not on the share, skipped', and the run exits 0 - a whole (ESM, scenario) forcing set can be silently omitted from the mirror; a listing failure should be distinguished from an empty listing and exit nonzero.
  • ⚠️ antarctica/README.md:268 - Stale rationale: the README still describes the cold-start CTRL warning with the retracted claim ('its apparent-MB baseline will not match hist-branched projections (a spurious trend in proj-CTRL)'), contradicting the corrected rationale in commit 8f873a7 (a_ref is a t=0 balancing correction, not a net sink; the overshoot is a real forced-response bias) and no longer matching the actual warning text in control/run.py:208-214.
  • ⚠️ antarctica/reports/MATRIX_STATUS.md:67 - MATRIX_STATUS.md open item 1 (lines 62-67) still asserts the retracted causal claim as fact ('The matrix sitting above the ISMIP6 envelope ... turned out to be the CTRL cold-starting ... with a mismatched frozen apparent-MB baseline' and that cores 9/10 'carry the spurious trend'); per the corrected rationale the overshoot is a real forced-response bias and the hist-branch control makes proj-CTRL larger (+161 vs +132 mm), not smaller, so this status doc now misstates the project's own conclusion.
  • ℹ️ antarctica/scripts/calibrate_melt.py:259 - calibrate_melt.py reduces with rank-local numpy sums (node_kgyr[mask].sum(), lumped.sum()) despite the Firedrake/PETSc setup working under MPI, so running it under mpiexec silently produces wrong per-basin K and K* with normal-looking output; an nprocs>1 guard would prevent silent misuse.
  • ℹ️ antarctica/scripts/launch_ctrl_when_ready.sh:1 - The three when-ready launchers are ~85% line-for-line identical (noclobber lock protocol, traps, resource readers, gate loop, NTAG awk, logging) across ~315 lines and have already drifted (boundary_ids preflight in 2 of 3, MIN_FREE_CORES sanity warning in 1 of 3); consolidating the gate/lock machinery into one sourced library or a single parametrized launcher would make future fixes to the subtle lock/launch logic land in one place.
  • ℹ️ antarctica/scripts/preflight.py:32 - The n-tag MAP-filename rule now has at least 6 implementations that must stay bit-identical (inversion_icepack2.py:94, preflight.py:32, simulation.py:65, plus awk one-liners in all three launchers); a divergence silently makes a launcher gate on, or a forward load, a differently-named MAP - at minimum the three Python copies should import one shared helper.
  • ℹ️ antarctica/scripts/launch_rc_inversion_when_ready.sh:72 - Unlike both CTRL launchers, the inversion gate never pre-checks boundary_ids.json (required at inversion_icepack2.py:152), so the gate can wait hours for resources, fire, die in seconds on FileNotFoundError, and retain the lock as an 'already launched' marker that masks the failure.
  • ℹ️ antarctica/scripts/simulation.py:790 - Because t_restart unconditionally overrides the caller's t_start, hist-branched projections/CTRL integrate 2014->end (not the nominal 2015->end): year 2014 is replayed under the scenario forcing with a typically absent (zero-filled) 2014 anomaly - consistent between CTRL and projections so it cancels in differences, but it shifts absolute timeseries and duplicates a year already simulated in the historical (related to tracked open item 2 in MATRIX_STATUS.md).
  • ℹ️ antarctica/scripts/projections/ocx.py:45 - OCX always cold-starts (setup_model() with no restart argument and no --restart/auto-resume handling), so a reboot of the 1990-2025 run at dt=0.1 silently starts over and rewrites the timeseries CSV from scratch even though valid self-contained checkpoints exist on disk.
  • ℹ️ icepack2_tools/coupled.py:139 - coupled.py forcing_callback multiplies melt_rate_ice_equivalent() by 31556926 assuming the plume model returns m/s and copies it dof-for-dof into ctx["ocean_melt"] assuming identical dof ordering, but no plume module exists in the repo to verify either assumption - a m/yr-returning plume would be inflated ~3.2e7x and a submesh would scramble the copy.
  • ℹ️ antarctica/scripts/compare_ismip6.py:169 - compare_ismip6.py does not re-baseline 'ours' after clipping to the ensemble overlap window, so a run starting well before ~2015 carries its accumulated pre-window signal as a constant offset against ensemble members baselined at their own t0; harmless for the standard 2015-start runs but wrong for earlier-start inputs.
  • ℹ️ .gitignore:3 - The bare 'data' pattern in .gitignore ignores any file or directory named 'data' at any depth, not just the repo-root data dir; '/data/' would scope it to the root.
  • ℹ️ antarctica/scripts/control/run.py:179 - Intent conformance verified: commit 8f873a7 touches only the comment block (control/run.py:179-196) and the two PETSc.Sys.Print operator messages; the hist-branch behavior, cold-start fallback, and restart validation are unchanged, and the corrected rationale (a_ref as t=0 balancing correction, +132 vs +161 mm isolation-test numbers, real forced-response bias, n=3 halving) is stated exactly as the intent requires; the retracted '681 Gt/yr / ~160 mm' arithmetic survives only inside this comment where it is explicitly quoted as retracted.

🔧 Fix: Align README and MATRIX_STATUS with corrected hist-branch rationale
2 infos still open:

  • ℹ️ antarctica/reports/MATRIX_STATUS.md:63 - The MATRIX_STATUS rewrite correctly retracts the wrong a_ref attribution, but it also dropped the still-true provenance note that cores 9/10 predate the hist-branch fix (their tracked CTRL records are cold-start controls, i.e. not the protocol-correct hist-branched control under the corrected rationale), and neither core09 nor core10 report records this itself - core09 even states 'This is the CTRL that the projections difference against'. Consider re-adding one sentence noting the archived core09/10 CTRLs are cold-start (mismatched-baseline) controls; ask-user since it is wording in the tracked scientific record.
  • ℹ️ antarctica/reports/MATRIX_STATUS.md:63 - Fix conformance verified: commit b6a3ccb changes only antarctica/README.md (cold-start warning description now matches the actual control/run.py:208-214 text - 'DIFFERENT geometry ... will not cleanly isolate the forced response' - and the branching sentence now attributes cancellation to shared relaxation drift plus the identical frozen a_ref) and MATRIX_STATUS.md open item 1 (restated as a real forced-response bias with the +161 vs +132 mm isolation test, a_ref as t=0 balancing correction, explicit retraction of the '~160 mm spurious a_ref trend' claim, and the n=3 halving to +85 mm vs the [-14,+50] envelope); no code or behavior was touched and no other files changed, matching both the user's fix instructions and the stated intent.

🔧 Fix: Restore cores 9/10 cold-start CTRL provenance note
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • AST comparison of antarctica/scripts/control/run.py at 8f873a7^ vs 9b0e36b with string literals normalized - identical, proving the change is comments/messages only
  • git show --stat b6a3ccb 9b0e36b - confirmed the follow-up commits touch only markdown (README, MATRIX_STATUS)
  • Ran run.py:main() end-to-end through the CTRL branch decision in the venv-firedrake-2026 environment (setup_model stubbed) for both paths: no historical endpoint (corrected cold-start WARNING printed) and fake historical endpoint present (corrected hist-branch message printed, hist-branch behavior kept)
  • Repo-wide grep for retracted-rationale text (spurious, apparent-MB baseline, 681, ~160 mm) - only explicit retractions and one unrelated numerics comment remain
  • Cross-checked intent numbers (+8/+37 mm drift, +132/+161 mm proj-CTRL, n=3 +85 mm, [-14,+50] envelope) against run.py comment, README, and MATRIX_STATUS
  • git status after harness run - worktree clean, fake endpoint h5 removed
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

hoffmaao and others added 30 commits May 7, 2026 18:28
The whole antarctica/mesh/ dir was gitignored to keep the large .msh/.h5
files out of git, but that also hid the 155-byte boundary_ids*.json files
that every solver script reads by default (ISMIP7_BNDIDS -> calving/other
physical-line ids for the calving-terminus BC). A fresh clone hit a missing
default path. Switch the rule to 'antarctica/mesh/*' + a '!boundary_ids*.json'
negation (a negation under the dir form is a no-op) and commit the four files.

These encode the gmsh $PhysicalNames split: addPhysicalGroup() auto-numbers
the alternating Calving_/Other_ segments 1,2,3,... so odd=calving, even=other.

Caveat: boundary_ids_buffered.json (55 ids) matches only the 4km/8km buffered
meshes; the finer buffered meshes have 35 ids (use boundary_ids.json). The
buffered sidecar is not yet per-mesh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s generator

- forcing.py: load_racmo_smb_climatology() reprojects RACMO ANT11 SMB to
  EPSG:3031 (rasterio) and samples to the mesh via icepack.interpolate
- control/run.py: use RACMO climatology as the SMB baseline (acabf fallback),
  report area-weighted mean SMB
- simulation.py: VAF/mass diagnostics use SI ice density (RHO_I_SI=917); the
  imported icepack rho_I is in MPa-m-yr units and was zeroing the output
- mesh.py / mesh_antarctica.py: loaders take data_dir so they resolve
  antarctica/data instead of <project>/data
- make_boundary_ids.py: generate boundary_ids.json from mesh $PhysicalNames

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The boundary-id sidecar only matches the specific mesh it was generated from
(its segment count is a deterministic function of the BedMachine input and
SIMPLIFY_TOL/SUBSAMPLE). Committing a fixed sidecar silently mismatches any
mesh built from different inputs. Instead, make_boundary_ids exposes an
importable write_boundary_ids(), and mesh_antarctica.py calls it after writing
each mesh so the sidecar always matches the local mesh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…orcing

Reconciles our local work with the upstream ocean-melt branch.

Conflict resolutions:
- simulation.py: keep upstream's ocean_melt mass sink and h_clamp=0; take
  upstream's _RHO_I_SI/362.5 for the VAF/mass diagnostic (same fix as ours),
  dropping our redundant RHO_I_SI/GT_PER_MM_SLE duplicates.
- control/run.py: RACMO climatology SMB baseline (ours) + per-basin ocean-melt
  callback (theirs); union imports; construct atm lazily in the acabf fallback.
- forcing.py: our load_racmo_smb_climatology alongside their Burgard melt
  parameterization (auto-merged).

boundary_ids policy: sidecars are now generated locally to match the mesh
(mesh_antarctica.py emits one), so .gitignore ignores the whole mesh dir and
upstream's four committed (35-segment) sidecars are dropped — they matched no
mesh reproducible from the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds make_synthetic_ocean_callback: prescribes TF(draft) = clip(tf_max *
|draft|/depth_ref, 0, tf_max) and runs it through the Burgard
quadratic_mixed_slope melt with constant salinity and the default scalar K.
Needs no ocean/meltMIP data, so it works without the Globus download.

Opt-in via ISMIP7_SYNTHETIC_MELT=1 (tunable with ISMIP7_SYNTH_TF_MAX /
ISMIP7_SYNTH_DEPTH_REF); the real per-basin K path remains the default and is
unchanged. Uncalibrated development stopgap, not protocol forcing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Debugging tooling for the diagnostic solver:
- simulation.py: opt-in SNES/KSP convergence monitoring (ISMIP7_SNES_MONITOR),
  routed to a per-run file via ISMIP7_SNES_LOG (ascii:<file>) or stdout, so
  concurrent debug runs don't interleave.
- control/run.py: --snes-monitor / --snes-log CLI flags that set those, plus a
  --restart <checkpoint.h5> option to resume a control run.
- simulation.py: clamp the loaded thickness to h_clamp on restart, so a run
  never resumes below the floor (no-op for the default h_clamp=0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- --tag (ISMIP7_RUN_TAG): suffix on experiment_name so concurrent/successive
  runs write distinct output files instead of overwriting each other.
- --checkpoint-interval (ISMIP7_CHECKPOINT_INTERVAL): control how often
  thickness+velocity checkpoints are saved (e.g. frequent snapshots for
  debugging). Default 100, so existing behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Default to antarctica_64000_500_aniso.msh (the mesh behind
inversion_icepack2_500.h5) instead of the 1 km aniso mesh, raise the
memory gate to 128 GB for the ~7M-DOF MUMPS factorization, and mark the
script executable. This is the configuration that produced
inversion_icepack2_rc_500.h5.
ISMIP7_FRICTION=regularized_coulomb makes setup_model() drive the
prognostic system from the inversion_icepack2_rc_<lc>.h5 MAP:

- geometry (thickness/bed/surface) and velocity_obs are taken from the
  checkpoint itself, so the Weertman anchor C_w0 — and hence the meaning
  of the inverted theta — is reproduced exactly; a node-ordering check
  guards the dof copy (also protects the existing Budd path).
- the diagnostic solves build_rc_residual on the LIVE prognostic (h, s):
  grounded gate, Coulomb cap c0*N, and driving stress all track the
  evolving geometry, so the grounding line migrates with exact-zero
  shelf drag and no thickness clamp anywhere (h_clamp_init defaults to
  0 in RC mode; h_visc_floor=10 m handles the ice-free buffer).
- composite alpha defaults to the inversion's 1e-2 in RC mode so the
  forward diagnostic is consistent with the MAP.
- setup_model now reports the initial velocity misfit vs observations
  (the rc_500 checkpoint predates its final report, so this is where we
  learn how converged that MAP is).

Verified at LC=32000 against the completed RC inversion: the forward
reproduces the checkpoint's saved velocity to rel L2 = 3.2e-9, and a
short dt=0.1 clamp-free prognostic runs cleanly.
The acabf-anomaly files are referenced to the ESM's 1960-1989
climatology, and no historical scenario exists in the local data tree —
so the projection driver's anomaly-only SMB was missing the entire
climatological accumulation, while the CTRL driver's climatology lookup
silently fell back to zero SMB. Fix both sides consistently:

- ssp585 driver: force with the full acabf(t) field (smb_anomaly=False,
  new make_forcing_callback knob). Against a CTRL held at the 2015-2029
  acabf mean, projection minus control is the forced signal relative to
  the 2015-centered climate with no anomaly re-referencing.
- control driver: compute the reference climatology from full acabf
  pooled over historical + projection scenarios in an env-configurable
  window (default 2015-2029, the locally available years); raise
  instead of silently running a zero-SMB control.
- both drivers pick up the calibrated per-basin K npz (falling back to
  the 2500 m calibration — 16 basin scalars remapped via the IMBIE2
  8 km grid, mesh-independent); ssp585 default dt is now 0.1 per the
  May dt sweep.
- launch_rc_ctrl_when_ready.sh: resource-gated launcher for the 500 m
  regularized-Coulomb CTRL2015 verification run (dt=0.1, 2015-2025,
  24 ranks), logging under antarctica/results/logs/ so run logs survive
  reboots.

End-to-end tested at LC=32000 through control/run.py with
ISMIP7_FRICTION=regularized_coulomb.
Bring Dan's fixes into the antarctica branch: RACMO2.4p1 SMB
climatology baseline (load_racmo_smb_climatology), SNES/KSP convergence
monitoring with per-run log routing, restart/argparse tooling in the
control driver, the synthetic depth-dependent melt stopgap
(ISMIP7_SYNTHETIC_MELT=1), and the boundary-ids generator
(make_boundary_ids.py; the static boundary_ids*.json leave tracking).

control/run.py resolution composes both sides: RACMO stays the primary
CTRL baseline (area-weighted mean report), the fallback is the pooled
historical+projection full-acabf climatology (anomaly means are wrt
1960-1989 and are not a baseline), and a missing climatology is a hard
error instead of a silent zero-SMB control (ISMIP7_ALLOW_ZERO_SMB=1 to
override). The OI-climatology + per-basin calibrated K path remains the
default ocean forcing.
SMB(t) = RACMO_clim + [aSMB(t) - mean(aSMB over the reference window)]:
the acabf-anomaly is referenced to the ESM's 1960-1989 climatology, so
re-referencing over the CTRL window (2015-2029 of it exists locally)
removes that mismatch, and the RACMO baseline keeps projection and
control on the same absolute SMB. Falls back to full acabf(t) when
RACMO is absent. make_forcing_callback grows an smb_baseline parameter
(per-node array added to the anomaly each step).

Also: run_simulation nsteps now rounds instead of truncating (a 285-yr
dt=0.1 run silently lost its final step), and the RC CTRL launcher
verifies boundary_ids.json exists (untracked on this branch) and pins
ISMIP7_BNDIDS for the run.
front, and an observation mask for the inversion

Investigating the mass gain Dan saw. A new per-step mass audit in
run_simulation attributes dM to SMB - melt - boundary outflux - calving
and exposes any created mass; at 32 km it immediately caught two real
leaks in the pre-existing scheme:

- the -h*div(u*phi) volume term is non-conservative for DG0 (spurious
  h*div(u) mass at thickness jumps; +4700 Gt/yr at the test front). For
  piecewise constants the facet upwind terms alone are the finite-volume
  scheme, so the volume term is now dropped by default.
- the L2 DG0->CG1 projection undershoots negative at steep fronts and
  the h floor then injects mass (+7600 Gt/yr step-1 at 32 km). Replaced
  by a lumped-mass projection (convex combination of adjacent cell
  values: bounded, no floor injection, integral-preserving).

With both fixes the audited budget closes to machine precision
(resid = 0.00, clamp = 0.0-0.2 Gt/yr). ISMIP7_LEGACY_TRANSPORT=1
restores the old scheme for comparison.

ISMIP7_FIXED_FRONT=1 (now set by the RC CTRL launcher) removes ice that
flows past the initially ice-free cells and tallies it as calving flux
- without it a buffered mesh has no calving sink (~1300 Gt/yr in
reality) and must gain mass. Only meaningful with a true-geometry
initial state (RC mode).

The inversion misfit now excludes nodes without velocity observations
(MEaSUREs ERR > 0 defines coverage; icepack zero-fills NODATA, which
read as "observed stationary" and prescribed friction where there is no
constraint). Coverage gaps are small - 0.7% of grounded, 0.5% of
floating area - so this is rigor, not the mass-gain culprit. obs_mask is
saved in inversion checkpoints; ISMIP7_OBS_MASK=0 reverts.
get_thermal_forcing/get_salinity now load one (variable, year) slice
out of its decadal chunk file into an in-memory nearest-neighbour
RegularGridInterpolator over (z, y, x), cached across steps. The
previous pointwise xarray .interp over the open_mfdataset dask graph
cost ~10 minutes per step even at 5k mesh nodes; the new path reads a
year in ~2 s and answers in ~1 ms, and is verified bit-identical to an
xarray nearest-neighbour reference over 2000 random points and drafts.
Nearest-neighbour also matches the OI-climatology CTRL path and the
per-basin K calibration. A 32 km SSP5-8.5 step now costs the same as a
CTRL step (~10 s after warm start, was ~675 s).

ISMIP7_K_SCALE multiplies the melt K in both the projection callback
and the CTRL driver (the calibrated per-basin K integrates 689 vs 865
Gt/yr observed on the 2500 m mesh; 1.26 matches the observed total).
The front mask was derived from the run's starting h, so a restarted
run (the CTRL -> projection chain) would re-mask cells that
legitimately retreated mid-run inside the observed extent and forbid
them from re-advancing. setup_model now exposes the t=0
BedMachine/inversion thickness as ctx["H_init"] (restart_from does not
overwrite it) and run_simulation builds the mask from that. Cold-start
behavior is unchanged (verified identical at 32 km).
Local working-notes patterns belong in each clone's .git/info/exclude
rather than the shared .gitignore, and the README's writeup pointer now
names only the tracked document.
All ESM-forced core experiments (historical 1/2, SSP projections 3-8)
are the same run with different (esm, scenario, period), so the nine
drivers are now thin shims over experiment.run_core_experiment. That
puts every fix from this week's work in force for the whole matrix at
once: RACMO baseline + aSMB re-referenced over one shared pool
(historical + ISMIP7_CLIM_SCENARIO, 2000-2029) so all cores and the
CTRL share a single baseline and the 2014/2015 historical->projection
handoff is seamless; calibrated per-basin K with the 2500 m fallback;
dt=0.1; hard fast-failure (seconds, before the expensive model setup)
when the forcing tree for an (ESM, scenario) is not downloaded, instead
of silently running anomaly-only/zero-SMB.

OCX (core 11) is rebuilt as a genuinely observation-forced run: RACMO
actual-year SMB (1979-2023) + constant OI-climatology ocean with
per-basin K — runnable entirely from local data. The OI-climatology
ocean callback moves from control/run.py into icepack2_tools.forcing
(shared by CTRL and OCX).

Verified at 32 km: the ssp585 shim reproduces the standalone driver's
results exactly; OCX runs with a closed mass budget; missing-data
drivers (historical, ssp370) fail in ~7 s with the download hint.

Co-authored work retained from prior drivers; fracture masks are loaded
when present but still not applied to the thickness update (open
protocol item).
Gate log, run log, and lockfile move from /tmp (wiped on reboot — the
Jun 20 rc_500 inversion log was lost that way) to the repo results
tree, tagged by resolution so a 2500 m and a 500 m gate can coexist.
The AIS_ocean/share_with_modellers tree is gone; data now lives under
CMIP6_test_protocol/AIS mirroring the runtime layout, so downloads land
directly in ISMIP7/AIS/ (no rename step, closing the README caveat).

Auth is now headless-first: the script's own refresh-token cache, then
the globus CLI token store (~/.globus/cli/storage.db keeps a long-lived
transfer refresh token + the per-user confidential client), then an
interactive login that now requests refresh tokens so it is one-time.
The destination endpoint defaults to the Globus Connect Personal id
registered on the machine. Transfers verify checksums.

Manifest updated to what the share actually carries today: the SDBN1
1960-1989 long-term means (the exact reference climatology of the
acabf-anomaly files), ocean ct/sa climatology+bias, the newer 06_nov OI
climatology (1972-2024), the ISMIP7 obs kit, IMBIE v3, grid, and
topography. NOT yet published upstream: historical yearly forcing,
processed ssp126/ssp370, all of MRI-ESM2-0 — and the processed ssp585
tree we hold locally is no longer on the share (treat it as
irreplaceable).

Co-existing raw CMIP output lives in ../CMIPraw for when processing
becomes necessary.
- build_oi_climatology_interpolators grows ISMIP7_OI_VERSION (default
  30_sep, matching the per-basin K calibration; 06_nov selects the
  reorganized share's 1972-2024 re-release). The 06_nov so/thetao files
  currently ship the tf field by mistake (upstream packaging bug,
  reported) - the reader refuses them with an explanatory error instead
  of silently melting with TF-as-salinity.
- load_K_per_basin falls back to the IMBIE v3 basin file when the
  calibration-era v2 is absent (verified identical basinNumber field,
  so the calibrated per-basin K remains valid with either).
- scripts/preflight.py: five-second per-core readiness table checking
  every input each experiment needs (mesh, MAP, boundary ids, K, RACMO,
  OI climatology, atmosphere/ocean coverage over the run period),
  honoring the same env knobs as the runs. Current verdict: cores 7, 9,
  11 READY (core 10 = core 9 while RACMO is the baseline); 1-6, 8
  blocked on unpublished upstream forcing.
Re-walked the whole GHub tree to diagnose what climate forcing is
pullable. The collection is mid-reorganization: CMIPraw is now gone
entirely, AIS/MRI-ESM2-0 does not exist, and AIS/CESM2-WACCM holds only
climatologies + bias ingredients (no scenario dirs, no per-year
forcing). Everything still pullable is already mirrored locally, so the
downloader is a no-op. The processed CESM2-WACCM/ssp585 forcing we run
from (133 GB) is no longer on the share — flag the local copy as
irreplaceable.
The 500 m RC CTRL crashed in setup_model's initial n=1->4 continuation
(DIVERGED_MAX_IT): a fine mesh + a rough mid-optimization MAP outruns
Newton across the 5-step ramp, and unlike the timestep loop the initial
solve had no retry. Now it restores the initial guess and re-ramps with
2x then 4x steps on divergence (ISMIP7_CONTINUATION_STEPS, default 8),
and reports the step count that worked. 32 km reproduces the identical
initial misfit and VAF/mass through the new path (no regression).
On the 500 m mesh each diagnostic solve is costly, so seed the adaptive
continuation finer (16) rather than failing at 8 and re-ramping.
Diagnosis of the 500 m CTRL divergence: the rc_500 MAP (a 20-iter
mid-optimization checkpoint) carries ~700 theta/phi nodes with |.| up to
23, i.e. exp() coefficients ~1e10 that are local singularities the SNES
cannot solve through — not a step-size issue. A converged MAP is O(1)
everywhere (rc_32000 maxes at 1.64). setup_model now clips
theta/phi to |.|<=ISMIP7_MAP_CLIP (default 6) and logs the count,
removing the pathology (0.1% of nodes) without touching real structure.
No-op for converged MAPs. Pairs with the adaptive continuation to
unblock the 500 m cold start pending a proper re-inversion.
Adds a minimum-yield shelf-drag knob to the RC forward and records the
multi-step instability diagnosis. The RC diagnostic velocity blows up
~2x/step in prognostic use (global, hypersensitive to sub-percent
geometry change) — confirmed independent of transport scheme (legacy
and conservative both blow up), independent of the fixed calving front
(on/off identical), and NOT curable by shelf drag (tested up to 50 kPa,
already enough to defeat RC's purpose). Budd ran stably for 10-20 steps
in May, so this is RC-specific: exact-zero shelf drag + composite
viscosity leaves the mixed diagnostic system near-singular as h evolves.
The knob and MAP clip / adaptive continuation stay as robustness
options; the forward prognostic needs Budd (proven stable) or a
monolithic implicit coupling.
Following the pivot away from the prognostically-unstable RC forward:
a resource-gated 2500 m Budd CTRL2015 validation run with the full
conservation pipeline (RACMO SMB, OI ocean + per-basin K, conservative
transport, fixed front, h_clamp_init=0, dt=0.1). Also confirms the
Budd + fixed-front + h_clamp_init=0 config (new since May, which used
h_clamp_init=10); a non-converging run self-terminates in ~10 steps so
gating is safe. The rc_ctrl launcher is superseded for forward use.
hoffmaao added 17 commits July 19, 2026 18:05
The 1873 hard eras are front-cell-emptying EVENTS in sequence: the
rescue ladder alone fails them at dt=0.1, but a dt=0.025 approach from
the 1870 checkpoint crossed the 1873.3 event with four consecutive
trust-region+limiter recoveries (then walled at the next event) - small
geometry increments let Newton track the branch through each event.

run_simulation's step is now transport-first (advance with the velocity
solved AT the current geometry, then solve at the new geometry - the
same (G, u) sequence as before, with the bonus that checkpoints store a
mutually consistent (h, u) pair). A step whose post-advance solve
exhausts the rescue ladder rewinds ITS OWN advance (h_dg/z entry
buffers) and retries it as dt/4 then dt/16 subcycles with full rescue
solves between substeps (ISMIP7_SUBCYCLES, default 1,4,16). Budget
tallies (outflux/calving/clamp/limit) accumulate over substeps into the
step's row, so the audit residual still closes exactly.

Validated: the 2-yr balanced CTRL is unchanged (flat VAF, resid 0.0,
ON TRACK, no rescue fires). Historical cores rerun next; their reports
record how the 1873+ era sequence resolves.
First core experiment of the matrix run end-to-end: full 1850-2014
window from the balanced init, 13 front-cell-emptying events crossed by
the dt-subcycle rescue ladder. Century-scale behavior is sound: SMB
2520, shelf melt 1371, front discharge 1050 Gt/yr means; dM/dt -314
Gt/yr; dVAF/dt -0.5 mm SLE/yr; budget residual <= 0.1 Gt/yr over 1640
steps. The single audit FAIL is the runaway detector's absolute-peak
clause on an isolated one-step discharge spike during an emptying event
(tallied, budget closed - a different signature from the sustained
exponential growth the clause was built for; left as-is pending a
ruling on refining the detector). Final state (t_yr=2014.0) is the
restart base for the CESM2-WACCM projections (cores 3/5/7).
Second core of the matrix, on the newly mirrored MRI forcing. Completed
in two legs: cold to 1876.9, then a warm resume whose x64 subcycle rung
crossed the 1877 Amery front-emptying event (a single 553 s step) and
cruised to 2014. Inter-ESM contrast established at 32 km: MRI gains
~+88 mm VAF over the window (stronger SMB) where CESM loses ~-87 mm.
Budget residual closed throughout; same isolated-spike audit caveat as
core 1. Final state (t_yr=2014.0) is the restart base for the MRI
projections (cores 4/6/8).
First projection of the matrix to run its full window: 2015-2100 from
the core-2 historical final, zero rescue events end to end (the MRI
forcing never produces a front-emptying era at 32 km within this
window). Budget residual closed throughout; audit table in-report.
Ensemble overlay lands with the CTRL pair (cores 9/10).
Full window in two legs: cold to the 2069.7 front-emptying wall, then a
warm resume that crossed it and ran out the window (17 rescued events
total). By 2100 the CESM ssp370 forcing has driven the system into a
surface-loss regime (net SMB -1987 Gt/yr, melt 5064 Gt/yr); VAF -265 mm
vs 2015. Budget residual closed throughout.
108+ years of ssp585 from the historical final - the configuration that
died at year 9 before the balanced-init/rescue work. 19 rescued events;
a warm resume in the deep-collapse era (net SMB -6659, melt 13,818
Gt/yr, VAF -584 mm vs 2015) buys only +1.3 yr: front-emptying eras are
continuous there and the split scheme saturates. Honest stop state
saved; the monolithic coupling port owns the 2124->2300 stretch.
Three more full 2015-2300 projections:
- Core 5 (ssp126 CESM): two legs across the 2056.4 wall.
- Core 6 (ssp126 MRI): one clean leg, no rescues.
- Core 8 (ssp585 MRI): 114 clean years to the 2129.0 wall, then a warm
  resume through the deep era to 2300 - where the CESM ssp585 twin
  (core 7) saturates at 2124.5, MRI's milder ocean lets the split
  scheme finish the window.
Budget residual closed on all three; per-core reports carry audits and
CTRL-differenced ensemble overlays.
- Core 9 (CTRL CESM): COMPLETE 2015-2300, the balanced control the
  projections difference against.
- Core 10 (CTRL MRI): PARTIAL to 2040.5 (split-scheme-edge wall its
  physical twin core 9 crossed; RACMO baseline makes them near-identical,
  so CTRL behavior is demonstrated by core 9).
- Core 11 (OCX): COMPLETE 1990-2025 obs-constrained; the real OCX
  forcing product (now on the share) is a follow-up swap.
Parallel line to antarctica running the composite viscous rheology at
standard Glen n=3 instead of n=4 Goldsby-Kohlstedt. n=3 is the
ISMIP6/ISMIP7 default and the more directly comparable configuration;
the n=4 branch is left untouched.

The n=4 assumption lived entirely in two env-var defaults, so this is a
small, self-documenting change:
- N_FLOW_DEFAULT 4.0 -> 3.0, A4_FACTOR_DEFAULT 10.0 -> 1.0 as module
  constants in simulation.py and inversion_icepack2.py. A0 =
  rate_factor(260 K) is already the n=3 fluidity, so the composite main
  term is plain Glen with no prefactor rescale (a4_factor=1); at n=4 the
  factor lifts A_3 to A_4 so A_4 tau_c^4 ~ A_3 tau_c^3 at tau_c.
- map_n_tag() gives n=3 MAPs an _n3 filename tag
  (inversion_icepack2_budd_n3_<lc>.h5) so they coexist on disk with the
  untagged n=4 MAPs; empty at n=4 (backward compatible). The forward and
  its inversion must use the same ISMIP7_N_FLOW / ISMIP7_A4_FACTOR.
- Launcher log/lock names n-tagged so n=3 and n=4 inversions don't
  collide on one machine.
- COMPOSITE_RHEOLOGY.md + antarctica/N3_FRAMEWORK.md document the branch.

Verified: syntax on all three scripts; map_n_tag() returns _n3 at the
n=3 default, '' at n=4; launcher dry-run emits budd_inv_n3 names.
Adopt the mismip_time-dependent-da (Recinos/fenics_ice) inversion method
to fix the n=3 theta/phi blow-up. The old n=3 MAP was ill-conditioned
(theta/phi to +-36 in observed ice; forward gave misfit 3.5e5 vs the
MAP's 1798, not forward-consistent) because the fluidity control
phi=log(A/A0) sat on a CONSTANT baseline, so phi had to carry all the
spatial fluidity structure and, without the n=4 a4x10 boost, exploded.

The fix regularizes the DEVIATION from a physical prior mean, not the
amplitude:
- icepack2_tools/thermo_model.py: depth-averaged enthalpy (Stefan) model
  ported from the mismip thermo_model, adapted for Antarctica with a
  spatially-varying surface-temperature BC + an under-relaxed fixed-u
  Picard driver (compute_fluidity_prior).
- icepack2_tools/prior.py: fenics_ice Whittle-Matern prior
  gamma*(theta^2 + L^2|grad theta|^2) - the theta^2 mass term (absent
  from the old gradient-only reg) removes the null space that let smooth
  large controls through.
- forcing.load_mean_annual_surface_temperature: ISMIP7 tas climatology.
- inversion: control phi=log(A/A_prior) on the thermomechanical prior
  mean (A4_base=A_prior, a Function; theta stays log(C/C_w0) on the
  balance anchor); WM reg at a physical gamma=1e4; A_prior saved in the
  MAP.
- forward (simulation.py): loads A_prior from the MAP and reconstructs
  A=A_prior*exp(phi); restart checkpoints carry it.
- antarctica/scripts/thermo_prior.py: standalone A_prior driver + figure.

Validated at 32 km (30-iter inversion): thermo prior converges (T
221-272 K, A 1-884), controls drop to theta [-2.7, 5.7], phi [-6.1,
0.64] (was +-36), misfit descends 7.6e3->5.7e3, and the forward
reproduces (setup_model initial misfit 6.7e3 vs the old 3.5e5; MAP clip
now touches 2 nodes, was ~600). Full-convergence inversion next.
… MAP)

At -n 16 on the tiny 32km mesh (~90 vertices/rank) MUMPS got fragile on
the stiff thermomechanical A_prior and the FIRST adjoint solve failed.
The adjoint/forward guards returned a large objective with a ZERO
gradient, which L-BFGS-B read as convergence at iteration 0 - it quit
and overwrote the good MAP with theta=phi=0 (misfit 1e11). Now a failure
on the first evaluation (iteration_count==0, no successful gradient yet)
raises a clear RuntimeError naming the likely cause (too many ranks for a
small mesh) instead of silently producing garbage. Mid-run failures still
backtrack as before. Fix: run small-mesh inversions at fewer ranks.
The full 32km n=3 Budd inversion with the thermomechanical fluidity prior
+ Whittle-Matern regularization converged (iter 534, misfit 4815,
n=4-comparable). Controls are physical - theta p99abs 2.93 (max 7.8),
phi p99abs 3.78 (max 5.9), vs the old p99 14-17 / max +-36 - and the
forward REPRODUCES the inversion (setup_model initial misfit 6191 vs the
old broken 3.5e5). Document the physical-prior method + run recipe in
N3_FRAMEWORK.md (few ranks for small meshes; ISMIP7_MAP_CLIP=10 at n=3).
The physical-prior inversion changed the fluidity baseline from the
constant a4_factor*A0 to the thermomechanical A_prior stored in the MAP:

- COMPOSITE_RHEOLOGY.md: composite spec now defines A = A_prior*exp(phi)
  with the legacy constant fallback noted, pointing to N3_FRAMEWORK.md
  (the owner of the prior method) instead of duplicating it.
- antarctica/README.md \S4: the default inversion now also needs the \S2
  ISMIP7 tas climatology (plus \S1 RACMO) for the thermo prior solve.
- inversion_icepack2.py header: note the physical-prior controls and
  Whittle-Matern regularization with a pointer to N3_FRAMEWORK.md.
- N3_FRAMEWORK.md: add ISMIP7_L_REG to the env list.

Lint (no linter configured; py_compile + AST unused-import scan on the
changed files): remove imports the change orphaned (grad, ds, dS in the
inversion after the regularization moved to icepack2_tools/prior.py)
plus pre-existing unused ones in the changed files.
hoffmaao added 5 commits July 26, 2026 13:43
experiment.py::run_core_experiment and projections/ocx.py now suffix the
experiment name with ISMIP7_RUN_TAG (matching control/run.py's --tag), and
the hist->projection restart lookup uses the tagged historical. This lets a
parallel method line (e.g. the n=3 matrix, ISMIP7_RUN_TAG=n3) write distinct
output files (hist_<esm>_n3_<lc>_final.h5, ssp585_<esm>_n3_..., ocx_n3_...)
that coexist with the n=4 results instead of clobbering them, with the
restart chain staying within one line.
The ISMIP6-ensemble comparison (compare_ismip6.py) now auto-selects the
emission-scenario pool from the projection filename (ssp126->rcp26;
ssp370/ssp585->SSP5-8.5+RCP8.5, best-effort from Seroussi 2020, --exps to
override) and, when our trajectory is outside the envelope, prints the
deviation direction plus a parametrization hint.

Running it on the n=4 matrix exposed a methodology bug: the scenarios sat
ABOVE the ISMIP6 envelope and were compressed (ssp126 +134 mm SLE at 2100
vs ensemble [-14,+50]; nearly as high as ssp585), a scenario-independent
bias. Root cause: the CTRL cold-started from the 2015 inversion, so its
frozen apparent-MB correction (a_ref) balanced to that geometry (net
-95 Gt/yr), while the projections warm-start from the drifted historical
endpoint and inherit its a_ref (-777 Gt/yr). a_ref is a constant per-step
sink, so the -681 Gt/yr mismatch never cancels in proj-CTRL: it is a
spurious -160 mm SLE trend over 2015-2100, larger than the whole +132 mm
ssp126 signal.

Fix: control/run.py branches the CTRL from hist_<esm>_<lc>_final.h5 (same
t0 state -> identical frozen a_ref as the projections, respecting
ISMIP7_RUN_TAG), cold-starting with a loud warning only when the historical
endpoint is absent. Verified at 32 km: the hist-branched CTRL loads
amb=-777 Gt/yr from the checkpoint, matching the projections exactly, so
proj-CTRL now cancels the apparent-MB baseline and isolates the forced
signal.

Claude-Session: https://claude.ai/code/session_01UrjGwCeaeZv566CpbVhE15
@hoffmaao hoffmaao changed the title n=3 standard-Glen line: physical-prior inversion (thermo fluidity + Whittle-Matern) feat(antarctica): n=3 ISMIP7 projection pipeline with ISMIP6-checked, hist-branched CTRL Jul 26, 2026
hoffmaao added 3 commits July 26, 2026 21:11
…ef artifact

The hist-branch CTRL change is kept (it is the protocol-correct same-state
differencing: the control and projections must branch from the same historical
endpoint so their shared relaxation drift cancels in proj-CTRL). But the
justification in the previous commit was wrong.

An isolation test at fixed n=4 rheology (same ssp126 projection, only the
control baseline changed) shows:
  - cold-start CTRL (dVAF +8 mm over 2015-2100)  -> proj-CTRL = +132 mm SLE
  - hist-branch CTRL (dVAF +37 mm)               -> proj-CTRL = +161 mm SLE

So the correct control makes the signal LARGER, not smaller: the projection's
historical-endpoint relaxation only cancels when the control shares it. a_ref
is a t=0 balancing correction (it zeroes the initial tendency), NOT a constant
net sink, so the earlier "681 Gt/yr x 85 yr = -160 mm spurious trend, remove it
and we fall into the ISMIP6 range" claim was invalid arithmetic and is
retracted. The ISMIP6 overshoot is a real forced-response bias (melt/dynamics
parametrization), not a differencing artifact; n=3 rheology roughly halves it
(ssp126 +85 mm, still above the tight [-14,+50] envelope). Comment and the
operator warning are corrected accordingly.

Claude-Session: https://claude.ai/code/session_01UrjGwCeaeZv566CpbVhE15
@hoffmaao hoffmaao changed the title feat(antarctica): n=3 ISMIP7 projection pipeline with ISMIP6-checked, hist-branched CTRL feat(antarctica): ISMIP7 core-experiment pipeline with n=3 rheology and hist-branched CTRL Jul 27, 2026
@hoffmaao

Copy link
Copy Markdown
Member Author

Moving staging to the fork (hoffmaao/ismip7) while this work matures; will reopen or file a fresh PR when it is ready for upstream review.

@hoffmaao hoffmaao closed this Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants