Skip to content

Reconcile bpsi/develop base into develop (bpsi↔fork merge baseline, CI-green) - #226

Open
HengyuLi-Ozaki-lab wants to merge 81 commits into
k-yoshimi:developfrom
HengyuLi-Ozaki-lab:merge/upstream-reconcile-2026-07
Open

HengyuLi-Ozaki-lab wants to merge 81 commits into
k-yoshimi:developfrom
HengyuLi-Ozaki-lab:merge/upstream-reconcile-2026-07

Conversation

@HengyuLi-Ozaki-lab

@HengyuLi-Ozaki-lab HengyuLi-Ozaki-lab commented Jul 12, 2026 •

Copy link
Copy Markdown
Collaborator

Contributing the bpsi↔fork merge baseline back to k-yoshimi/task

Cover letter for an upstream contribution — what the baseline is, what changed, and how it was tested.
Prepared 2026-07-10. Full engineering detail: docs/2026-06-TASK-merge-and-TRX-report.md.


Status on 2026-10-21 (read this first)

This description was written in July. Since then the pieces split out of this PR went into develop in their reviewed form (#256 eq_save, #257 MCP fd isolation, #265 the mcp pin), and develop (ea9806ac) is merged into this branch (a4e91044; the merge message lists how each of the 43 conflicts was resolved). What this PR still adds on top of develop: the bpsi base and its merge, the #227/#228 fixes (#228-2, -3, -4 in 5565ede1, 0dd157d4, 6a804077; #228-16 in 19a552b4), get_psi_rz (eqlib and the eq_mcp tool), the CI job that builds the modules pytest does not touch, and the regenerated baselines.

The wrx baselines are regenerated from CI in the species-major layout (cc7d12b8), and the change behind them is recorded for the physics sign-off in docs/superpowers/decisions/2026-10-21-wrx-rl-binning-baseline.md: the bpsi base re-bins the rl deposition of wrx/wrcalpwr.f90; pwr_tot and every rs quantity are unchanged. §4.2 below is corrected accordingly. What is still open is listed in the comment of 2026-10-21. Where this description says "gfortran-13.2" or "gf13.2", the CI compiler was 13.3.0; 13.2 is the apt metapackage revision (docs/baseline-policy.md).


0. TL;DR for a reviewer

This proposes bringing the Kyoto bpsi/develop base and the GitHub Phase-L modernization fork
into one reconciled, CI-green baseline. The merge is already done, resolved, and validated:

  • Merge commit 0254aa91 — 1st parent 58f1fe2e (Phase-L fork tip), 2nd parent fa9dd493
    (bpsi/develop, +23 commits). i.e. bpsi/develop merged into the fork, keeping every
    modernization and grafting bpsi's intent.
  • CI-green code baseline d1f7f9e7 — 736 passed on Python 3.11 + 3.13; three 1e-10 equivalence
    baselines regenerated on the CI compiler (gfortran-13.2), one of them for an owner-approved physics
    refinement.
  • 10 files had genuine conflicts; each was resolved as keep the fork's modernization, graft bpsi's
    intent
    (table in §3).

Upstream already incorporated (§6): this branch merges current k-yoshimi/develop in (73 commits —
mono-libtotapi routing #201/#204/#208, the Linux-canonical equivalence policy #213, the Python
fixture-parity backfill #215, and more) and adds the 57-commit bpsi baseline on top: 58 ahead / 0
behind
. Only .gitignore needed manual conflict resolution (unioned); the merge co-modifies just 5
non-Fortran files (all clean unions) and no Fortran file, so nothing upstream is dropped. Offered as
a PR, never a force-push.


1. What the baseline is

Three git lines had drifted apart:

Line Repo Role
bpsi bpsi.nucleng.kyoto-u.ac.jp/pub/git/task Kyoto main dev — latest physics / bug-fixes
fork (Phase-L) github.com/k-yoshimi/task fork F90-ification, per-module C-ABI + libXapi.so + Python wrappers + MCP servers, trcomm split into 6 sub-modules
ats-fukuyama github.com/ats-fukuyama/task stale 2023 backup (read-only; not a merge input)

The merge brings the latest upstream base onto the modernized fork without regression. An audit
established bpsi/develop is the latest base (not a 3-way upstream merge): ats-fukuyama/develop is 119
commits behind bpsi and its "unique" commits are superseded backups. So the merge is bpsi base → the
Phase-L fork
.

7d18264e  merge-base (fork point vs k-yoshimi/develop)
   ├─ bpsi/develop        +23 commits → fa9dd493   (latest base)
   └─ Phase-L fork tip                → 58f1fe2e   (C-ABI/.so/MCP/6-module split)
        0254aa91 = merge(1st parent = fork 58f1fe2e, 2nd = bpsi base fa9dd493)  ⇒ bpsi merged INTO the fork
        + build-integration + baseline fixes → d1f7f9e7  (CI green, delivered)

2. What the contribution changes, in aggregate

Before this merge, the baseline was 57 commits ahead / 73 behind the current k-yoshimi/develop (this PR incorporates all 73 — see §6, now 0 behind). The 57
ahead touch 162 files (+9,637 / −5,522). The bulk is the bpsi merge itself (the 0254aa91 merge
alone reconciles 128 files) plus the CI-greening fixes and the design/plan docs. The net intent:

  1. Graft bpsi's latest physics/bug-fixes — chiefly the eq/tr q-solver refinement (finer
    flux-surface grid, EQLOOP under-relaxation) used by the modelg=3 eq_load path.
  2. Keep every Phase-L modernization — the C-ABI libXapi.so layer, Python wrappers, MCP servers,
    and the 6-way trcomm split.
  3. Make the whole thing build and pass CI green for the first time on the sandbox.

3. Conflict resolutions (10 files)

Each conflict = keep fork modernization, graft bpsi intent:

File Resolution
eq/eqinit.f90 graft bpsi values into free-form: NTVMAX 200→400, NLPMAX 20→100
eq/eqcalc.f90 graft bpsi EQLOOP adaptive under-relaxation
eq/eqcalq.f90 graft NPRINT print + NMAX 200→400
eq/eqsub.f90 fixed-form continuations → free-form; richer diagnostic
pl/Makefile keep noeqlib recipes + take bpsi SRCS_NOEQ + plview.f90
tr/trcomm.f90 take-ours (6-module split) + idempotent DEALLOCATE_TRCOMM
tr/trcomm_param.f90 species param list 8 → 15 (add PNM/PTM/PTPR/PTPP/PUM/PUPR/PUPP)
tr/trmain.f90 take-theirs (drop double-free)
txnew/txcalv.f90 take-theirs (SAVE-workspace allocation)
wrx/wrcalpwr.f90 genuine 3-way blend: keep headless/OOB clamp + graft bpsi dx

4. How it was tested

4.1 The build had never been CI-green on the sandbox

Even the pure base develop failed — the build never reached pytest. Getting to green surfaced issues
class by class (auto-merge artifacts, a PT symbol clash between ti/wm, a pl_view module move that
broke fp at link time, a repo-name assumption, and test-env gaps). Final:

736 passed, 6 skipped, 1 xfail (pre-existing), 1 xpass — CI green on Python 3.11 + 3.13.

d1f7f9e7 carries a two-reviewer pre-push sign-off marker.

4.2 The 1e-10 equivalence gate

The project keeps per-case regression baselines compared at a 1e-10 tolerance. Three drifted on
the CI compiler (gfortran-13.2), for two distinct reasons:

  • wrx_demo / wrx_iter01 — pure compiler-version FP reordering (gf8.5 ≠ gf13.2 ≠ Mac gf15, all
    within ~1e-9). Lesson recorded: 1e-10 binary equivalence is too tight for FP-heavy modules across
    compiler versions
    ; baselines must be captured on the exact CI compiler. Regenerated on gf13.2.
    (Corrected 2026-10-21: that holds for the pwr_tot failures it describes, but the comparator of the time
    compared only pwr_tot for wrx. The same merge re-bins the rl deposition of wrx/wrcalpwr.f90; once fix(test_run): compare wrx arrays/arrays2 and fix WrxState.to_dict orientation (#239) #249
    compared the arrays this showed, and the three wrx baselines are regenerated from CI with it. See
    docs/superpowers/decisions/2026-10-21-wrx-rl-binning-baseline.md.)
  • tot_ht6m_short — a real physics shift, relative up to 7.9e-4 (see §5), owner-approved and re-baselined.

Because the CI env couldn't regenerate these directly, an env-gated capture (REGEN_OUTPUT_DIR)
dumps each case's freshly computed metrics as a CI artifact; those gf13.2 values were committed as the
new baselines.

4.3 Reproduce

make -C tr tr2 libtrapi.so tr_api_check_all
make -C tot libs && make -C tot tot_api_check_all && make -C tot tot
PYTHONPATH=python:python/mcp-servers python -m pytest python/ \
    --forked --timeout=120 --timeout-method=signal        # 736 passed on gf13.2

5. The one deliberate physics change: tot_ht6m q-solver refinement

tot_ht6m_short (integrated eq→tr coupled run) shifted by up to 7.9e-4 relative (corrected 2026-10-21; it said "~1e-4"). Root cause: bpsi's merged
EQ-solver refinements — finer flux-surface grid (NMAX 200→400) + EQLOOP under-relaxation — on the
modelg=3 eq_load path. Signature (all 50 radial rows):

  • current profile redistributes (j up in the core, down mid-radius to −7.9e-4 @ ρ=0.74, up at the
    edge; q is its integral: down core, up outer);
  • total current conserved (AJT Δ 3.9e-5 ≪ local Δ 7.9e-4);
  • density frozen (0/50 rows); smooth, no NaN.

Reviewed and owner-approved as an intended higher-accuracy solve; the baseline was regenerated on
gf13.2 accordingly. (A truncated CI log initially mis-characterized this as core-localized; two
independent reviewers caught it against the full committed baseline — the write-up was corrected to
global redistribution.)


6. What this PR actually contains, and how it was validated

It is a merge, not a rewrite. The branch is merge/upstream-reconcile-2026-07; its tip is a merge
commit whose parents are the delivered baseline (c6cee542) and the current k-yoshimi/develop
(4a5d977d). Result: 58 ahead / 0 behind develop.

The merge is clean by construction — the two sides co-modify only 5 non-Fortran files, each a
verified clean union: .gitignore, .github/workflows/python-tests.yml, python/eqlib/_ffi.py,
python/totlib/tests/test_equivalence.py, python/wrxlib/tests/test_equivalence.py.

Local validation (macOS, gfortran-15):

  • make -C tr tr2 (static) builds from the merged sources; lib/mtxp/pl/eq/bpsd all compile.
  • pytest --collect-only python/trlib python/totlib collects 229 tests with no import/fixture breakage.

Deferred to this PR's Linux CI — by upstream's own policy:

So the full 736-test suite (including the equivalence gate that this whole exercise protects) is
re-verified by the CI attached to this PR, against the merged tree.


7. Key commits

0254aa91  merge: reconcile bpsi/develop base (23 commits) into the Phase-L fork
d1f7f9e7  CI-green code baseline (736 passed, 3 baselines regenerated on gf13.2)
c6cee542  delivered tip / merge 1st parent (= d1f7f9e7 + 8 docs-only commits)
58f1fe2e  1st parent — Phase-L fork tip (design spec + P1 plan)
fa9dd493  2nd parent — bpsi/develop base (q-solver coupling fix)

ats-fukuyama and others added 30 commits January 21, 2026 13:56
For web-client EQ→TR file coupling. Adds ~25 lines to eq_api.f90.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Exposes the eq_save C-ABI symbol (added in Task 1.1 / SHA eb55843) as
Eq.save(path): sets KNAMEQ then calls eq_save, raising EqlibError on
non-zero rc. Adds ctypes prototype in _ffi.py and a TDD integration
test that verifies file existence and non-zero size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add handle_save handler and @mcp.tool() save registration; update
--print-tools list and the test_print_tools_output count from 11 → 12.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… channel

Fortran's WRITE(6,...) maps to OS fd 1 (process stdout), which is the same
fd the MCP stdio transport uses for JSON-RPC framing. Any Fortran diagnostic
line written to fd 1 corrupts the channel and causes 'Connection closed'.

Add _redirect_fortran_stdout_to_stderr() context manager to eq_mcp and tr_mcp
servers: saves fd 1, dup2s fd 2 (stderr) over fd 1 before Fortran calls, then
restores fd 1 afterwards. Applied to eq.run(), eq.save(), and tr.run().

Do NOT use fflush(NULL): on macOS/Python it also flushes the Python-level
asyncio write buffer while fd 1 still points to stderr, sending the pending
JSON response to stderr and causing Connection closed on the client side.
Fortran WRITE(6,...) is line-buffered, so output reaches stderr before fd 1
is restored without an explicit flush.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previous redirect protected fd 1 during the Fortran call but didn't
flush libc's stdout buffer. Pending Fortran output then leaked into
the MCP pipe after restoration, corrupting JSON-RPC on subsequent
calls (notably get_state after run). Now we fflush() libc's C-level
stdout FILE* before restoring fd 1; this does NOT affect Python's
asyncio writes because Python writes via syscalls, not via C stdout.

Also extend the redirect to cover set_param, set_param_str,
set_params, get_state, and finalize in both eq_mcp and tr_mcp,
since those handlers also call Fortran routines that may print.
Add setvbuf(stdout, NULL, _IONBF, 0) at server startup in both eq_mcp
and tr_mcp so that any Fortran WRITE(6,...) output is emitted immediately
rather than staying in libc's buffer after the fd-level redirect has been
torn down. Also add _gfortran_flush_i4 (Fortran-level I/O buffer flush
for unit 6) inside _redirect_fortran_stdout_to_stderr() so that
gfortran's own internal buffer is drained into stderr before fd 1 is
restored to the JSON-RPC pipe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…imi#194)

Adds belt-and-suspenders fd-isolation at module load time, beyond the
per-call redirect already in 69d3d9d4. Disables _gfortran_flush_i4 in
the long-lived server path to avoid SIGABRT during teardown. Uses
os._exit(0) in main() to bypass Python finalizers that race with
Fortran cleanup.

Local-only patch on kyoshimi-develop; will be re-evaluated after
upstream macOS build support (f8f5c9e) lands and macos_stubs.f90
is dropped (which may make the SIGABRT-on-teardown class obsolete).
@k-yoshimi

Copy link
Copy Markdown
Owner

Re-verified the three #228 P1 items at head 682d9afe (reading the branch, not re-running the author's proofs). All three are still open; nothing on this head touches them:

  • PR #226 follow-up: fix non-MCP regressions in PL/TR/TRX/WM/W1 #228-2 — pl/plparm.f90:176-178 still has IF(MODELG.NE.3) THEN; IF((MODELG.LT.0).OR.(MODELG.GT.11)) ... 'XX plcheck: INVALID MODELG', while eq/eqfile.f90:117 accepts MODELG.EQ.25. MODELG=25 is therefore still rejected by plcheck before it can reach the EQDSK path.
  • PR #226 follow-up: fix non-MCP regressions in PL/TR/TRX/WM/W1 #228-3 — tr/trbpsd.f90:13 declares tr_eq_init = .TRUE. (SAVE); tr/trmetric.f90 clears it after the initial MODELG=9 q-scaling, but neither tr_api_finalize nor tr_api_init (tr/tr_api.f90) nor tr_init (tr/trinit.f90) mentions it, so init -> MODELG=9 run -> finalize -> init -> MODELG=9 run still skips the initial EQ adoption on the second cycle.
  • PR #226 follow-up: fix non-MCP regressions in PL/TR/TRX/WM/W1 #228-4 — tr/trmetric.f90 MODELG=9 block: tr_bpsd_get(ierr) errors are printed and execution continues, tr_bpsd_put(ierr) is not checked, sfac = tr_rip_eq/rips is divided by without a zero / non-finite check, exhausting the 30 iterations is silent, and tr_eq_init=.false. is set on every exit path.

Suggested shape, so they land in the remainder of this PR (or as a 4th small PR after the three in my previous comment): #228-2 is a one-line widening of the plcheck range (or an explicit MODELG.EQ.25 allowance) with a plparm test; #228-3 is tr_eq_init = .TRUE. in tr_api_init (and tr_api_finalize), plus the lifecycle test from #227; #228-4 is ierr propagation out of tr_set_metric (return a non-zero ierr from the MODELG=9 block on any of those conditions so tr_prep / tr_run surface TR_ERR_CALC_FAILED), which is the same contract #189's fix (PR #254) and #242 use on develop.

Status of the gate items on my side: #239 is fixed in PR #249 (comparator now compares arrays / arrays2 element-wise with shape checks; WrxState.to_dict() emits the *_nsa_nray blocks species-major like wrxregress.f90), CI + Bugbot green, merge pending. Once it is in develop, the wrx sign-off will be against a comparison that actually runs; I will post the quantified per-array shift of this branch against the develop baselines here as the record for the physics sign-off.

@k-yoshimi

Copy link
Copy Markdown
Owner

wrx gate: quantified shift and a baseline-layout problem

Compared the committed wrx baselines on develop (a0b58408) with the ones this branch regenerated (682d9afe), both Linux-CI products, with the array-aware comparator from PR #249 (every element of arrays / arrays2, shapes checked against the dimension header). wrx_jt60 is untouched (0 mismatches). For wrx_demo / wrx_iter01:

quantity wrx_demo wrx_iter01 reading
scalars.pwr_tot identical (1e-15) identical total absorbed power conserved
pos_nrs, pwr_nrs_nsa identical identical rs (minor-radius) deposition unchanged
pos_nrl +drl/2 on every bin (rel 1.5e-5 .. 6.8e-3) same (8e-6 .. 2.4e-3) wrcalpwr.f90: drl=(rlmax-rlmin)/nrlmax, bins now cell-centred
pwr_nrl_nsa 145 of 400 bins differ, rel up to 0.24 99 of 400, rel up to 0.99 per-bin redistribution: a different binning of the same deposited power, not a change of the deposition itself
pwrmax_rl_nsa_nray 1.4315e-3 -> 1.4243e-3 (-0.5 %) 0.11408 -> 0.11368 (-0.35 %) peak value
pos_pwrmax_rl_nsa_nray 0.30868 -> 0.30961 (+0.3 %, one bin) 0.46068 -> 0.47044 (+2.1 %, one bin) the ~2e-2 peak shift

So the "2e-2 deposition-peak shift" is the rl peak moving by one (re-centred) bin; the rs profiles and the totals are bit-identical. That is the record I would put in front of the lead for the physics sign-off: the change in wrx/wrcalpwr.f90 is a discretisation fix of the rl binning (cell-centred positions, consistent (rs1-rs2) weighting), with power conserved.

Baseline layout (blocks #249 after it merges). The regenerated wrx_demo / wrx_iter01 baselines on this branch carry the five *_nsa_nray blocks as rows = ray ([NRAYMAX][NSAMAX_WR]), i.e. they were captured through WrxState.to_dict() (the REGEN_OUTPUT_DIR hook this branch adds), whereas wrx/wrxregress.f90 and the develop baselines write rows = species ([NSAMAX_WR][NRAYMAX]). The comparator in #249 checks each block's shape against the header, so these two files will fail as soon as #249 is in develop (pos_pwrmax_rl_nsa_nray (actual): shape (4, 2) does not match the header {'NSAMAX_WR': 2, 'NRAYMAX': 4}). #249 also fixes to_dict() to emit species-major, so after rebasing on it either source produces the canonical layout; please regenerate wrx_demo / wrx_iter01 once more at that point (the Fortran writer or the fixed to_dict(), not the current one). That settles the "one canonical arrays2 layout" item: species-major, as the writer always did.

@k-yoshimi

Copy link
Copy Markdown
Owner

Follow-up, after building libwrxapi.so from 682d9afe and replaying the three fixtures through the wrapper (macOS, so expect the usual ~1e-6 platform drift on top):

  • Against the develop baselines the library reproduces exactly the table above for wrx_demo / wrx_iter01 (rl bins re-centred, rl peak -0.5 % / -0.35 % value, +1 bin position; rs profiles and pwr_tot within platform drift).
  • wrx_jt60 is shifted the same way (pos_nrl +drl/2, pwr_nrl_nsa per-bin up to 0.65 relative, pwrmax_rl_nsa_nray -0.46 %, pos_pwrmax_rl_nsa_nray +0.98 % = one bin) but its baseline was not regenerated on this branch. With fix(test_run): compare wrx arrays/arrays2 and fix WrxState.to_dict orientation (#239) #249's comparator that case fails on Linux, so please regenerate wrx_jt60 together with wrx_demo / wrx_iter01 after the rebase (species-major layout, see above). Today it passes only because the old comparator looked at pwr_tot.
  • Against the branch's own regenerated wrx_demo / wrx_iter01 baselines the profiles agree to platform drift (<= 1.6e-6), and the five *_nsa_nray blocks fail the shape check on both sides (shape (4, 2) does not match the header {'NSAMAX_WR': 2, 'NRAYMAX': 4}), which confirms those files were captured through the pre-fix(test_run): compare wrx arrays/arrays2 and fix WrxState.to_dict orientation (#239) #249 to_dict().

@HengyuLi-Ozaki-lab

Copy link
Copy Markdown
Collaborator Author

The split is up, in your order:

  1. feat(eq): eq_save, Eq.save() and the save tool of eq_mcp, with errors reported #256 eq_save, Eq.save() and the save tool with the error reporting, the header and the docstrings. develop had no eq_save, so it brings the feature. Reviewing it turned up four more silent failures of a save (a read-only target ended the process; MODEFW 1, 2 and 4 to 7 read unit 5, the JSON-RPC pipe of a stdio server; a save of a state that is not an equilibrium returned success; eq_run(1) reported a failed EQDSK load as a success). They are closed there for eq's own entry points and listed in the description, with what is left.
  2. fix(mcp): eq_mcp, tr_mcp and tot_mcp keep Fortran output off the JSON-RPC pipe #257 the stdio isolation from main() for eq_mcp, tr_mcp and tot_mcp, in fix(fp_mcp): redirect Fortran WRITE(6,...) to stderr to protect JSON-RPC channel #232's form, with the import-isolation tests.
  3. The trbpsd allocation fix: not opened yet, because I would like your choice first. The branch is ready (fix/trbpsd-plasmaf-extent on the fork, get side as in this pull request plus the put side of PR #226 follow-up: fix non-MCP regressions in PL/TR/TRX/WM/W1 #228-22), but measuring it changed my view of it:

On your three comments of today:

  • PR #226 follow-up: fix non-MCP regressions in PL/TR/TRX/WM/W1 #228-2, -3, -4: understood, and the shapes you give are what I will write: the plcheck allowance for MODELG=25 with a plparm test; tr_eq_init = .TRUE. in tr_api_init and tr_api_finalize with the lifecycle test; ierr out of tr_set_metric for the MODELG=9 block. All three touch code that only this branch has (tr_eq_init and tr_rip_eq, the MODELG=9 q-scaling, MODELG=25 in EQ_READ), so they go into the remainder here rather than into a fourth pull request against develop.
  • wrx: thank you for the quantified table; that is the record. After fix(test_run): compare wrx arrays/arrays2 and fix WrxState.to_dict orientation (#239) #249 is in develop I will merge develop into this branch and regenerate wrx_demo, wrx_iter01 and wrx_jt60 once, species-major, from the Fortran writer. Nothing is regenerated before that.
  • After feat(ffi): .dll names and DLL search directory on Windows #240 and the split pull requests are merged I bring this branch up to date by merging develop into it (not a rebase: the branch is also merged downstream), taking develop's version where the split pieces now live.

HengyuLi-Ozaki-lab and others added 3 commits October 14, 2026 02:37
pl/plinit.f90 documents MODELG=25 (EQDSK geometry) and EQ_READ in
eq/eqfile.f90 loads it, but plcheck refused every value outside 0..11:

   XX plcheck: INVALID MODELG: MODELG=          25

with pl_parm returning 101. The PL menu's P command (mode 0) asks again
on that, so it could not be entered there; a plparm namelist (mode 1)
and a "MODELG=25" line (mode 2) set the value and report the error.

plcheck now allows 25 by name; the range itself is not widened, and the
checks of that branch (model_prof, MODELQ) still run for it.

Test: python/eqlib/tests/test_plcheck_modelg.py. No C entry point reaches
pl_parm (the main programs and the PL menu call it), so the test compiles
a 12-line driver against the PIC build of pl that the shared libraries are
linked from, with the FCFREE of make.header, and reads back pl_parm's ierr.
Six cases: 25 accepted, 5 accepted, 24, 26 and -1 refused, and
"MODELG=25,MODELQ=7" refused for MODELQ only. Without the change in
plparm.f90 the two cases with 25 fail, with the message above.

Not changed:
  - MODELG 12 and 13 (documented in plinit since before this branch), 14
    (a case of pl/plprof.f90) and 15 (loaded by EQ_READ) are refused by
    the same range check, on develop as well.
  - Nothing but EQ_READ knows 25: pl/plprof.f90, tr_set_metric,
    tr_bpsd_get and the KNAMEQ check of eq_validate name 3, 5 and 8
    (tr_bpsd_get also 9), and dp/dpdisp.f90 treats every MODELG above 10
    like 0 and 1 (PL_PROFW3D).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imi#228 finding 3)

tr_bpsd_get adopts EQ's dpsi/dV into RDPVRHOG/RDP while the saved flag
tr_eq_init (tr/trbpsd.f90) is set; tr_set_metric cleared it after the
initial q-scaling and nothing set it again. Measured through libtrapi
with MODELG=9, the other parameters at the tr_init defaults (macOS,
gfortran 15, -O0 -fcheck=all, as everything measured below):

  - second tr_init .. tr_finalize lifecycle in one process: tr_run returns
    0 and every q is NaN (RIPS = 1 and 3; AJT = 0 at RIPS = 1);
  - second tr_prep inside one lifecycle (a parameter set after a run):
    tr_run returns 0 and the prepared state is not the first one, AJ
    differs by up to 57% (RIPS = 3). By reading, the menu's second R
    takes the same path (R is tr_prep followed by tr_loop).

tr_set_metric now sets tr_eq_init at the start of the MODELG=9 block:
tr_prep is its only caller and every tr_prep builds a new initial state.
tr_init and tr_finalize of the C API restore the flag as well, as k-yoshimi#228
proposes; alone they repair the lifecycle case only (measured: without
the line in tr_set_metric the second-preparation test below still fails).

After the change a second and a third lifecycle are bit-identical to the
first, and a state prepared again inside one lifecycle has the profiles
(QP, AJ, RN, RT) of the first, bit for bit. A first lifecycle is
unchanged: MODELG=9 with RIPS = 1, 3 and 10 (5 steps) and RIPS = 1 with
NTEQIT = 2 (6 steps), each in a process of its own, give bit-identical
states before and after.

Tests: python/trlib/tests/test_modelg9_init.py, four cases (finite first
lifecycle near the commanded current; second lifecycle; third lifecycle;
second preparation in one lifecycle). The last three fail without the
change. They run on the tr_init defaults, RIPS = RIPE = 3 set by name,
where the q-scaling agrees after three equilibrium solves without a
warning; with RIPS = 1 it takes seven and two of the solves run out of
NLPMAX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…finding 4)

The initial q-scaling of tr_set_metric (MODELG=9) went on after every
kind of failure: eq_calc had no way to report one, the status of
tr_bpsd_get was printed, that of tr_bpsd_put ignored, the scale factor
Ip(EQ)/RIPS was divided by unchecked, 30 iterations without agreement
ended silently, and tr_eq_init was cleared on every path.

Now each of these ends tr_set_metric with ierr /= 0 (tr_prep stops,
tr_run returns TR_ERR_CALC_FAILED), the cause on unit 6 and tr_eq_init
still set:

  - eq_calc fails. eq_calc (eq/equnit.f90) has an optional ierr; with it
    the routine stops at the first of its four steps that returns a
    status (eq_bpsd_get, EQCALC, EQCALQ, eq_bpsd_put) instead of running
    EQCALQ and the put on an equilibrium that was not solved. EQLOOP
    running out of NLPMAX (EQCALC's 100) is not a failure there: EQCALC
    goes on after it. The line on unit 6 names the iteration of the
    scaling. Called without ierr it does what it did; trx and trm call it
    that way and are not touched (both were compiled against the new
    interface, make -C trx libtr2.a and make -C trm libtr2.a). trn uses
    the stub module equnit_mod of pl/noeqlib.f, not this routine.
  - tr_bpsd_get or tr_bpsd_put returns a status.
  - the scale factor is zero or not finite (ierr = -1).
  - no agreement within 1e-3 after 30 iterations (ierr = -2).

RIPS <= 0 ends the loop after the first solve as before (by reading: on
the defaults the first solve fails for RIPS = 0 and -1). ierr is set to
0 at the top of tr_set_metric: for a MODELG without a branch it was
returned unassigned.

Only the first of the four was reached by the inputs tried. Measured
through libtrapi, MODELG=9, RIPS = RIPE varied, the rest at the tr_init
defaults, a build with -O0 -fcheck=all (macOS, gfortran 15), one step:

  RIPS                              before              now
  0.01 0.1 0.3 0.5 0.7 0.75 0.8     process ends (*)    CALC_FAILED
  0.81                              returns 0           CALC_FAILED
  0.82                              process ends (*)    CALC_FAILED
  0.83                              returns 0           the same state
  0.84                              returns 0           CALC_FAILED
  0.85 0.9 0.95 1 1.2 1.5 2 3 5     returns 0           the same state
  10 10.5                           returns 0           the same state
  11 12 13 14 14.5 15 20 25 30 100  process ends (*)    CALC_FAILED

  (*) at a run-time check: an unallocated array in bpsd_spl1D, or index
      0 of ya at eqcalq.f90:1431.
  "the same state": AJT, Q0, q at the first and the last point and AJ at
  the first print the same (shortest form that reads back exactly); for
  RIPS = 1, 3, 10 (5 steps) and RIPS = 1 with NTEQIT = 2 (6 steps) the
  whole state is bit-identical.
  CALC_FAILED is EQCALC's 103 (the axis leaves the plasma) everywhere but
  at 0.84, where it is EQCALQ's 103.

So two of the sampled inputs, 0.81 and 0.84, returned 0 before and fail
now. Their q-scaling went through solves that had failed (50 and 7
"XX EQAXIS" lines) and tr_run still returned 0, with AJT = 0.809 and
0.838 after the step; whether the scaling had agreed or run out of its
30 iterations the old code did not say. The same path ends the process
at 0.82. After a failure, a good run on the same handle or in a new
lifecycle is bit-identical to a first run.

The other three branches were each run once in a build with a fault
injected after the call (a status from tr_bpsd_get, from tr_bpsd_put,
Ip(EQ) = 0, NaN, Inf, a limit of 2 iterations): TR_ERR_CALC_FAILED every
time, and the good run after it bit-identical to a first run. That build
is not part of the commit. With the bpsd this branch builds against, the
two put checks cannot fire: every bpsd_put_data sets ierr = 0.

TR and EQ baseline cases (tr_iter01, tr_tst2, tr_m0904, the defaults, a
current ramp; eq_iter01, eq_tst2, the analytic default) through the
libraries: bit-identical before and after.

Tests: five cases added to python/trlib/tests/test_modelg9_init.py (an
equilibrium that cannot be solved, RIPS = 100, fails the preparation, in
a first lifecycle and in a later one; the line on unit 6 names eq_calc
inside tr_set_metric, read from a child process because the Fortran
run-time writes unit 6 out when the process ends; a good run after the
failure on the same handle equals a first run; so does the next
lifecycle). The failing runs are run(0), which prepares and takes no
step, so that an error of the transport step cannot stand in for the
preparation's. All five fail on the previous commit: in a first
lifecycle the process ends at the run-time check, in a later one
tr_run(0) returns 0 on the failed equilibrium.

Not changed, and still able to hide a failure or end the process:
  - A status that EQCALC or EQCALQ do not return. MODELG=9 with RA = 1.8
    (or 2.0), the rest at the defaults: EQMAGS fails inside EQCALQP
    (eqcalq.f90:235, its status is not tested), EQCALC and EQCALQ return
    0 up to there, and a build with run-time checks ends at
    eqcalq.f90:1431, before and after this commit. EQSOLV prints a
    BANDRD failure and goes on; EQCALQ overwrites the status of
    EQSETS_RHO with that of EQSETS.
  - tr_bpsd_get returns the status of its last bpsd_get_data only,
    tr_bpsd_put that of its last bpsd_put_data, eq_bpsd_get and
    eq_bpsd_put that of their last call.
  - tr_loop's periodic eq_calc (NTEQIT) ignores the outcome, as do trx
    and trm. With NTEQIT = 1 and a failing solve after two good steps
    (BB zeroed through the module variable's symbol; NTEQIT is not in
    the registry) tr_run returns 0 and takes the next steps.
  - EQLOOP at NLPMAX is accepted inside the scaling (the EQ menu and
    eq_run stop on EQCALC's 100): the runs that return 0 at RIPS = 0.83,
    0.85, 0.9 and 1 carry 8, 5, 6 and 2 such solves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@HengyuLi-Ozaki-lab

Copy link
Copy Markdown
Collaborator Author

#228-2, -3 and -4 are on the branch, one commit each, in the shapes you gave. Two places go further than the shape, because measuring showed the shape alone did not close the finding; both are marked, so that you can take them out.

#228-2 (5565ede1): plcheck allows MODELG=25 by name; the range is not widened and the other checks of that branch still run for it. The test compiles a 12-line driver against the PIC build of pl (no C entry point reaches pl_parm) and reads back pl_parm's ierr: 25 and 5 accepted, 24, 26 and -1 refused, MODELG=25,MODELQ=7 refused for MODELQ only. Not changed: the same range check refuses 12 and 13, which plinit documents, 14, which plprof has a case for, and 15, which EQ_READ loads (so on develop too); and nothing but EQ_READ knows 25 (plprof, tr_set_metric, tr_bpsd_get and the KNAMEQ check of eq_validate name 3, 5 and 8).

#228-3 (0dd157d4): tr_eq_init = .TRUE. in tr_api_init and tr_api_finalize, and also at the start of the MODELG=9 block of tr_set_metric. With the two resets alone, a second tr_prep inside one lifecycle (a tr_set_param after a run; by reading, the menu's second R) still skips the adoption: the test for it fails, and the prepared AJ is off by up to 57 %. tr_prep is the only caller of tr_set_metric and each call builds a new initial state, so the flag is set there. Before the change a second lifecycle returned 0 from tr_run with every q NaN; after it a second and a third lifecycle are bit-identical to the first, and a state prepared again inside one lifecycle has the profiles of the first, bit for bit.

#228-4 (6a804077): the MODELG=9 block returns ierr /= 0, so tr_run returns TR_ERR_CALC_FAILED with the cause on unit 6 and tr_eq_init still set, when tr_bpsd_get or tr_bpsd_put returns a status, when the scale factor is zero or not finite, and when 30 iterations do not agree. Beyond your list, eq_calc gets an optional ierr, and the loop stops on it. The reason: no input I found reaches the four conditions of the list (with this bpsd every bpsd_put_data returns 0, and tr_bpsd_get does not fail after a solve that worked), while a failed solve is what inputs do reach, and the old flow then ran EQCALQ and eq_bpsd_put on it. Called without ierr, eq_calc does what it did; trx and trm call it that way.

What that does, measured through libtrapi with MODELG=9, RIPS = RIPE varied and the rest at the tr_init defaults (macOS, gfortran 15, -O0 -fcheck=all, one step):

RIPS before now
0.01, 0.1, 0.3, 0.5, 0.7, 0.75, 0.8 the process ends at a run-time check TR_ERR_CALC_FAILED
0.81 returns 0 TR_ERR_CALC_FAILED
0.82 the process ends TR_ERR_CALC_FAILED
0.83 returns 0 the same state
0.84 returns 0 TR_ERR_CALC_FAILED
0.85, 0.9, 0.95, 1, 1.2, 1.5, 2, 3, 5, 10, 10.5 returns 0 the same state
11, 12, 13, 14, 14.5, 15, 20, 25, 30, 100 the process ends TR_ERR_CALC_FAILED

So two of the sampled inputs, 0.81 and 0.84, returned 0 before and fail now: their q-scaling went through solves that had failed (the axis leaves the plasma, status 103) and tr_run still returned 0; whether the scaling had agreed or run out of its 30 iterations the old code did not say. The same path ends the process at 0.82. I think stopping is right, since the step after a failed solve reads what was put from it, but it is a change of behaviour and it is yours to refuse. For RIPS = 1, 3 and 10 and for RIPS = 1 with NTEQIT = 2 the whole state is bit-identical before and after, as are the TR and EQ baseline cases through the libraries. After a failure, a good run on the same handle or in a new lifecycle is bit-identical to a first run.

The three other branches (a status from tr_bpsd_get or tr_bpsd_put, the factor, the 30 iterations) were each run once in a build with the fault injected after the call, which is not committed: TR_ERR_CALC_FAILED every time. They have no test.

Not changed, each seen while measuring:

  • A status that EQCALC or EQCALQ do not return still gets through. MODELG=9 with RA = 1.8, the rest at the defaults: EQMAGS fails inside EQCALQP (eqcalq.f90:235, its status is not tested) and a build with run-time checks ends at eqcalq.f90:1431, before and after these commits.
  • EQLOOP running out of NLPMAX (EQCALC's 100) is accepted inside the scaling, as EQCALC goes on after it; the EQ menu and eq_run stop on it. The runs that return 0 at RIPS = 0.83, 0.85, 0.9 and 1 carry 8, 5, 6 and 2 such solves.
  • tr_loop's periodic eq_calc (NTEQIT) ignores the outcome in the same way: with NTEQIT = 1 and a failing solve after two good steps, tr_run returns 0 and takes the next steps. NTEQIT is not in the registry, so the C API does not reach it. It is three lines with the new argument; say if you want it here.
  • tr_bpsd_get returns the status of its last bpsd_get_data only. That belongs with the trbpsd question (3) above.

Tests: python/eqlib/tests/test_plcheck_modelg.py (6 cases) and python/trlib/tests/test_modelg9_init.py (9 cases, on RIPS = RIPE = 3, the default, where the scaling agrees after three clean solves, and RIPS = 100 for the failure). On macOS the suite is 771 passed, 35 skipped, 1 xpassed, against 756 passed before. No test or baseline used MODELG=9 until now, so CI on this push is the first run of that path on Linux; the equivalence tests do not run on macOS either.

The entries that 5565ede, 0dd157d and 6a80407 added at the top of
[Unreleased] sit where develop (f41fe5b) added sections of its own
since this branch last merged it. The pull request stopped merging
cleanly, and its workflows do not run while it conflicts. CHANGELOG.md
goes back to what 682d9af had. The three entries are kept outside the
repository and are to be put back by hand, under develop's "### Fixed",
when develop is next merged into this branch. No code or test changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@HengyuLi-Ozaki-lab

Copy link
Copy Markdown
Collaborator Author

The push made this pull request conflict with develop, in CHANGELOG.md only: the three entries sat at the top of [Unreleased], where develop has its own sections now, so no workflow ran. d8b6e4de takes the entries out again (the file is as at 682d9afe); they go back under develop's ### Fixed when develop is merged into this branch.

Two tests of this run will be red, and not because of #228: python/wrxlib/tests/test_equivalence.py for wrx_demo and wrx_iter01. The run checks out this branch merged with develop, so #249's shape check meets this branch's wrx baselines, which are still laid out [nray][nsa] (pwr_nsa_nray is 1 x 2 and 4 x 2 here, 2 x 1 and 2 x 4 on develop). The three wrx baselines are regenerated with the merge of develop, as you asked; the Linux output of this run is what I will regenerate them from.

k-yoshimi pushed a commit that referenced this pull request Oct 17, 2026
… reported

Split out of #226 at the maintainer's request: the save of an equilibrium
through the library, together with its error reporting (#227 item 3), so
that the contract ships with the code.

What is added
- eq_save() in the C ABI (eq/eq_api.f90, eq/eq_api.h): writes the
  equilibrium of the last eq_run to the TASK/EQ binary file named by
  KNAMEQ, through EQSAVE.
- Eq.save(path) in eqlib: sets KNAMEQ, calls eq_save.
- The `save` tool of eq_mcp (12 tools now).
The file is the one MODELG=3 with KNAMEQ loads.

A save that wrote nothing is an error
- EQSAVE has an argument now, IERR_OUT: 0 when the file was opened and
  written; FWOPEN's IERR when it refused the file; 9 when the file was
  opened and its first record could not be written. That last case is an
  existing file without write permission: FWOPEN opens it for reading and
  returns 0, and the first WRITE used to end the program. EQSAVE returned
  silently on a refused open before.
- eq_save returns EQ_ERR_NOT_INIT before eq_init. It returns
  EQ_ERR_INVALID, touching no file, when the fields do not hold the
  equilibrium of the present parameters: no successful eq_run since
  eq_init, a later solve or load that failed (it leaves the fields
  part-way), or
  eq_set_param of anything but MODEFR and MODEFW since (EQSAVE writes the
  parameters and counts as they stand over fields computed for the old
  ones). What EQSAVE writes in those states can stop the process that
  loads it. It returns EQ_ERR_INVALID too for a blank KNAMEQ, and for
  MODEFW other than 0 (overwrite an existing file) or 3 (refuse one): with
  1 or 2 FWOPEN asks Y/N on unit 5 before it overwrites an existing file,
  with 4 to 7 it asks for the file name there, and unit 5 of a stdio
  server is the JSON-RPC pipe. It returns EQ_ERR_CALC_FAILED when EQSAVE
  reports that
  the file could not be opened (a directory that is missing or not
  writable, MODEFW=3 and an existing file) or not written, or when no
  non-empty file exists afterwards. Looking for the file alone is not
  enough: a save to a path that already holds an earlier file would pass
  for a success.
- eq_run(1) reports a load that failed, which the precondition above
  rests on. EQ_READ called EQCALQ after EQDSKR had failed and handed back
  EQCALQ's code, and left IERR unassigned for a MODELG that is no file
  format: on develop the load of a missing EQDSK file (MODELG=5) ends the
  process at a STOP, exit status 0, in a build with run-time checks. EQ_READ
  now starts from IERR=0, calls EQCALQ only after a successful EQDSKR, and
  returns 1 for an unknown MODELG. (The product line has had these three
  lines since September.)
- Eq.save takes a str or an os.PathLike, and refuses a path that ends in a
  blank (Fortran drops the blank and would write another file) or is
  longer than the 80 bytes of KNAMEQ, with EqlibInvalidParamError.
- The other call sites of EQSAVE pass a variable now: the `S` command of
  the eq menu, and tr/f77/trfile.f, a file no Makefile builds, whose
  `CALL EQSAVE(4)` fitted neither the old routine nor the new one.
- eq_mcp described MODEFR and MODEFW as "frequency-space mode switch" and
  "wave-frequency mode switch"; they are the file modes of FROPEN and
  FWOPEN, and say so now.

Tests
- python/eqlib/tests/test_save.py (20 cases, in a temporary directory with
  relative names): the saved file loads back with MODELG=3 and gives the
  grids and the two profiles it was saved with, exactly, and five scalars
  that are recomputed after the load, to 1e-9; a pathlib path and
  something that is no path; no run yet; a run that failed after a good
  one; a failed EQDSK load after a good run and a changed parameter; a
  parameter set after the run; the file modes set after the run; a
  missing directory; a blank path; 80 bytes taken and 81 refused; a path
  ending in a blank; a repeat save refused by MODEFW=3; a read-only file;
  MODEFW 1, 2, 4, 5, 6 and 7.
- python/eqlib/tests/test_load_errors.py (3): the load of a missing file
  with MODELG 3 and 5, and with a MODELG that is no file format, is
  EqlibCalculationFailedError.
- python/mcp-servers/eq_mcp/tests/test_save_tool.py (6): the handler, the
  two error mappings, the tool list, one sequence through the real
  library, the two descriptions.
- eq/tests/c_abi/test_negative.c: eq_save before eq_init. The Makefile
  target that runs it does not link in a build without the graphics
  library (on develop neither, and CI does not run it); built against
  libeqapi.so directly, it passes.
- With a fault put in and the library rebuilt: eq_save ignoring EQSAVE's
  argument fails the repeat-save and the read-only tests; EQSAVE's first
  WRITE without IOSTAT fails the read-only test; no MODEFW check fails the
  six MODEFW cases; no check for an equilibrium fails two tests; the flag
  not cleared when a run starts, or not cleared by a setter, fails the
  test of that case; the flag cleared by the file modes too fails two.
- One real stdio session (initialize, init, save, run(mode=0), save, save
  into a missing directory): "invalid parameter: eq_save: rc=1" and no
  file before the run; a 45956-byte file after it; "calculation failed:
  eq_save: rc=3" for the missing directory.

python/ with CI's flags, macOS arm64, gfortran 15: 802 passed, 35 skipped,
1 xfailed (develop a0b5840: 773 passed). The state after a run is
bit-identical with develop: the two EQ fixtures that load a file and the
analytic default (512 values each), and tr_tst2, tr_iter01, tr_m0904, the
TR defaults and a current ramp.

Not changed, and known
- EQSAVE writes PSI, DELPSI and HJT as (NSG,NTG) blocks where the arrays
  are (NTG,NSG) and the reader reads them so: the same bytes when NSGMAX =
  NTGMAX (the default, 32 and 32), transposed extents otherwise (reported
  on #226). Older than this change, and a fix alters the file for unequal
  grids: left for its own pull request.
- Only the first WRITE of EQSAVE has an IOSTAT: a failure later in the
  file (a full disk) still ends the program.
- MODEFR=1 makes the load path ask for a file name on unit 5 in the same
  way; not touched.
- eq_save's precondition is eq_api's own: set by eq_run, cleared by
  eq_run and eq_set_param. An equilibrium that another module computes
  through EQ inside the mono image does not set it, and a parameter changed
  by another route does not clear it: tot's `eq:` names call the registry
  directly, and in the mono image the other modules' setters write the
  shared plcomm (measured there: eq_run(0), tot_set_param("eq:NRGMAX", 65),
  eq_save returns EQ_OK).
- EQ_READ is also reached through eq_load (tr, fp, wr, wrx), EQLOAD (ob,
  wm, wmx) and the L and K commands of the eq menu. tr, fp, wr, wrx, wm
  and wmx ask for MODELG 3, 5 or 8, where a successful load is as before
  (K sets MODELG=5 itself). The L command passes on whatever MODELG is set
  (2 as 3), and ob/obprep.f90 calls it for any: for a MODELG that is no
  file format they now get IERR=1.
- Without the stdio isolation of the companion pull request, EQSAVE's
  messages on unit 6 reach the JSON-RPC pipe like the rest of EQ's output
  (5 of the 11 stdout lines of the session above are not JSON-RPC).
- get_psi_rz, the other tool of the same group of #226, is not here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@k-yoshimi

Copy link
Copy Markdown
Owner

FYI: the mcp pin from this PR (c528b58d + af197409, author preserved) is now a standalone PR against develop, #265, with the floor raised to mcp>=1.2,<2 (mcp/server/fastmcp first ships in the 1.2.0 wheel). The hunks for test_get_psi_rz_tool.py and the numpy install were left out since neither exists on develop yet. On the next rebase of this branch those two commits should show as already applied; what remains here is the test's guards, the numpy install, and a trivial 0.9 vs 1.2 conflict in the same strings.

HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 21, 2026
No line conflict. What the merge changes for this branch without one:
- k-yoshimi#233 put a scalars object into fp_mcp's state schema, zero after a
  set until the next run; set_param_str opens that window too, which
  the schema text did not say yet (next commit).
- k-yoshimi#256 brought eq_mcp's save tool, which this branch's docs still
  called future, after k-yoshimi#226 (fixed in a later commit).
- k-yoshimi#232 and k-yoshimi#257 moved fp_mcp's fd isolation into python/_fd_isolation;
  this branch's server.py keeps it.
- k-yoshimi#261 added the layout queries fp_state_abi_version and fp_state_size
  to libfpapi.so; fplib refuses a library without them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Oct 21, 2026
With develop in this branch, a few sentences no longer hold:
- eq_mcp's save tool is in develop (k-yoshimi#256), so "once k-yoshimi#226 lands" goes
  from the module docstring, the set_param_str tool docstring and
  README section 8.4.
- The get_state tool docstring lists the returned keys and now names
  scalars too (the first LOW of the k-yoshimi#233 approval, left for this PR).
- libfpapi.so exports eight C symbols since k-yoshimi#261: README section 2 now
  names the six entry points and the two layout queries fplib checks.
- The Sphinx FP MCP pages (en, ja) still said nine tools and that the
  string setter is not reachable through MCP; they now list ten tools,
  with a set_param_str row, and point at README section 8.4.
- fp_mcp/__init__.py's docstring said nine tools.
- The comment above SupportedValue says that this server exposes the
  string setter as the set_param_str tool, as suggested on k-yoshimi#231.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HengyuLi-Ozaki-lab and others added 5 commits October 21, 2026 10:09
develop now carries the pieces split out of this branch (k-yoshimi#256 eq_save,
k-yoshimi#257 the MCP fd isolation, k-yoshimi#265 the mcp pin) and k-yoshimi#232, k-yoshimi#233, k-yoshimi#240, k-yoshimi#242,
k-yoshimi#243, k-yoshimi#248, k-yoshimi#249, k-yoshimi#251, k-yoshimi#254, k-yoshimi#255, k-yoshimi#258, k-yoshimi#259, k-yoshimi#261, k-yoshimi#263, k-yoshimi#264. 43
files conflicted; each was resolved as follows.

- Taken from develop (36 files): every file where this branch only
  changed the mcp pin to "mcp>=0.9,<2" (develop has ">=1.2,<2", k-yoshimi#265):
  the seven pyproject.toml, six READMEs, the fp, ti, tot, wr and wrx
  servers, the Sphinx MCP pages, the trlib presentation and its
  builder, the 2026-04-18 MCP plan; and the eq_save pieces now in
  develop in their reviewed form: eq/eq_api.f90, eq/eq_api.h,
  python/eqlib/tests/test_save.py, eq_mcp/tests/test_save_tool.py; and
  tr_mcp/server.py, whose changes here were this branch's own fd
  isolation (develop's _fd_isolation replaces it) and the os._exit(0)
  at the end of main().
- The os._exit(0) that ended main() of tr_mcp and eq_mcp on this branch
  (to skip the Python teardown, cited in k-yoshimi#227 item 5) is gone with
  them: develop's main() returns 0, as approved in k-yoshimi#257. The abort it
  avoided was a double DEALLOCATE, which this branch fixed in
  tr/trcomm.f90 (DEALLOCATE_TRCOMM returns when nothing is allocated);
  that fix stays, merged without a conflict.
- eq/eqfile.f90: develop's (EQSAVE without an argument, EQSAVE_IERR
  doing the work, and its kept EQDSKR error), plus this branch's
  MODELG=25 in EQ_READ's EQDSK branch (k-yoshimi#228-2). eq/eqmenu.f90 merged
  without a conflict but kept this branch's "CALL EQSAVE(IERR)"; it is
  develop's "CALL EQSAVE" again, as EQSAVE takes no argument.
- python/eqlib/_ffi.py, eqlib.py, tests/test_eqlib.py: develop's, plus
  this branch's get_psi_rz (the eq_common_get_psi_rz_ prototype, the
  Eq.get_psi_rz method, its closed-handle assertion).
- eq_mcp/server.py: develop's (the save tool of k-yoshimi#256, the shared fd
  isolation), plus this branch's get_psi_rz handler and tool and its
  --print-tools entry (13 tools). eq_mcp/tests/test_server.py: this
  branch's (13 tools with save and get_psi_rz), which contains
  develop's change.
- python/mcp-servers/eq_mcp/tests/test_import_isolation.py (this branch
  only, no conflict) is removed: it pinned this branch's own isolation
  helper (_install_fd_isolation, _FD_ISOLATION_INSTALLED in eq_mcp and
  tr_mcp), which develop's python/_fd_isolation.py replaces; develop's
  tests/test_fd_isolation.py of each of the seven servers checks the
  same contract (an import leaves fd 1 and sys.stdout alone).
- .github/workflows/python-tests.yml: develop's mcp pin; this branch's
  numpy install (the get_psi_rz tests import numpy) and ../task symlink
  step kept.
- CHANGELOG.md did not conflict. The three k-yoshimi#228 entries this branch held
  back in d8b6e4d are added to [Unreleased] Fixed, after its first
  entry, where no other open pull request inserts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docstring and the skip reason of test_get_psi_rz_tool.py still said
"mcp>=0.9,<2", the pin this branch carried before k-yoshimi#265 moved it to
develop as ">=1.2,<2" (mcp.server.fastmcp first ships in 1.2.0). They
name develop's pin now; the guards themselves are unchanged. The
"trivial 0.9 vs 1.2" K. Yoshimi mentioned on 2026-10-18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
develop replaced the make.header heredoc each build job carried (with a
"KEEP IN SYNC" comment) by the composite action
.github/actions/provision-build-env, used by the pytest and mono-build
jobs. This branch's uncovered-modules-build job still had its own copy
and a comment pointing at steps that no longer exist. The copy was
identical to the action's heredoc; the job now uses the action too,
and the action's description names the four jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
eq_mcp registers 13 tools: develop's 12 (with save) and this branch's
get_psi_rz. The README (--print-tools count, Tools table), the Sphinx
MCP pages (en, ja: the totals, the --print-tools line, the tool
catalog) and the module docstring's list of EQ-specific tools said 12
and did not list get_psi_rz (on this branch they said 11 before the
merge). They now name it, with its numpy dependency. The Sphinx pages'
"tr has 9" becomes 10, what tr_mcp registers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…major, for the rl binning

The bpsi base (7999438) re-bins the rl deposition of
wrx/wrcalpwr.f90: drl=(rlmax-rlmin)/nrlmax instead of /(nrlmax-1), and
pos_nrl at the cell centres. With k-yoshimi#249's comparator, which compares
arrays and arrays2 element by element and checks their shapes, this
branch's wrx_demo and wrx_iter01 baselines failed the shape check
(ray-major, captured before k-yoshimi#249 fixed WrxState.to_dict; the values are
the same) and wrx_jt60, never regenerated here, failed on values.

The three metrics.json are now the values the CI runner captured in
run 38690362398 (2026-10-15, k-yoshimi#226 merged with the develop of that day,
which had k-yoshimi#249; artifact equivalence-actuals-3.11-38690362398, the 3.13
one byte-identical), species-major. Nothing in wrx/ or python/wrxlib/
that changes the values moved between that develop and ea9806a.
Against develop's baselines, with develop's comparator: pwr_tot and
every rs quantity equal; pos_nrl moves in every bin, from +drl/2 at the
first to -drl/2 at the last; pwr_nrl_nsa differs in 145, 99 and 91 of
400 bins; the bin of the rl maximum is the same, and the fitted peak
value changes by -0.50/-0.35/-0.46 %. pos_pwrmax_rl_nsa_nray changes by
+0.30/+2.1/+0.99 %, all of it in the fit's offset term: the formula
adds that offset in metres to a bin index scaled to 0..1. The fit's
vertex at the major radius moves by about half a new bin (+1.5, +10.4,
+4.9 mm).

docs/superpowers/decisions/2026-10-21-wrx-rl-binning-baseline.md
records this for the physics sign-off (K. Yoshimi's quantification on
k-yoshimi#226 of 2026-10-13, measured again, with jt60); the 2026-06-29 record,
whose wrx failures were pwr_tot compiler drift, gets a note on the
array change it could not see; docs/baseline-policy.md gets the new
provenance of the three baselines. The 1e-10 tolerance is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@HengyuLi-Ozaki-lab

Copy link
Copy Markdown
Collaborator Author

develop (ea9806ac) is merged in (a4e91044), and the wrx baselines are regenerated. The branch is no longer conflicting, and CI on the head cc7d12b8 is green: all ten checks, including the six clean-tree builds and the job that builds the modules pytest does not touch. The wrx equivalence tests ran on Linux and passed: python/wrxlib/tests/test_equivalence.py ... in both pytest jobs of run 39534443181, each 1186 passed, 19 skipped (mono-image and ti-fixture tests, no equivalence test), 3 xfailed; gfortran 13.3.0.

The merge. 43 files conflicted, all where develop now has the pieces split out of this PR (#256, #257, #265), or next to them (#261's layout functions in eq/eq_api.f90). The merge message lists each resolution.

  • 36 files are develop's: there this branch had only the 0.9 pin, or the eq_save / isolation code that develop now has in its reviewed form.
  • The rest keep develop's plus what only this branch has:
  • Two things went with the superseded code:
  • eq/eqmenu.f90 merged without a conflict but kept CALL EQSAVE(IERR). It is develop's CALL EQSAVE again, since EQSAVE takes no argument now.
  • The "0.9 vs 1.2" strings you mentioned are fixed (d9673f1c).
  • The uncovered-modules job uses .github/actions/provision-build-env (87772dd3), and the eq_mcp docs list 13 tools (64f615ac).

The wrx baselines (cc7d12b8). wrx_demo, wrx_iter01 and wrx_jt60 are now the values the CI runner captured in run 38690362398 (#226 with the develop of 10-15, which had #249), in the species-major layout. In that artifact Python 3.11 and 3.13 are byte-identical, and nothing that changes wrx values moved in develop since then.

For the sign-off, docs/superpowers/decisions/2026-10-21-wrx-rl-binning-baseline.md measures your 10-13 table again, with jt60 added. Two corrections to that table:

  • pos_nrl is not +drl/2 in every bin. The bins are also narrower (L/N instead of L/(N-1)), so the shift runs from +drl/2 at the first bin to -drl/2 at the last.
  • The rl peak does not move by one bin. The bin of the maximum is the same (62, 94, 96).
    • pos_pwrmax_rl_nsa_nray is (locmax-0.5)/(nrlmax-1) - dpwr/ddpwr: a bin index scaled to 0..1, plus the parabolic fit's offset in metres. So all of its +0.30 / +2.1 / +0.99 % (the "~2e-2 shift") is the offset.
    • The fit's vertex at the major radius moves by about half a new bin: +1.5 / +10.4 / +4.9 mm.
    • That formula is the same on develop; I left it for the lead.

pwr_tot and every rs quantity are unchanged. The June record called the wrx failures compiler drift, and for the pwr_tot failures it showed, that holds: on the CI compiler pwr_tot agrees to about 6e-16 before and after. What it could not see is the array change; a note there says so.

The gate, as I read it now:

item state
CI: mcp pin, mono eqdata develop (#265) and this branch; green on cc7d12b8
wrx: comparator, layout #249
wrx: baselines cc7d12b8, from CI
wrx: physics sign-off the record above, for the lead
#228-2, -3, -4 5565ede1, 0dd157d4, 6a804077 (you said you would re-check them on the next head)
#228-16 19a552b4
eq_api.h / save docstring develop's #256 versions

Not answered, and outside the gate.

Locally on the head (macOS, gfortran 15, clean build with the local build script's flags, CI's pytest flags): 1076 passed, 39 skipped (every equivalence test is Linux-only; the rest need the mono image, the ti fixture or /dev/full); the mono step's four files 20 passed.

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.

6 participants