Repository navigation
feat(eq): eq_save, Eq.save() and the save tool of eq_mcp, with errors reported - #256
Conversation
… reported Split out of k-yoshimi#226 at the maintainer's request: the save of an equilibrium through the library, together with its error reporting (k-yoshimi#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 k-yoshimi#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 k-yoshimi#226, is not here. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
k-yoshimi
left a comment
There was a problem hiding this comment.
Review of 411971c (in-house + two independent Codex passes, plus a local build and test run; details at the end). The C ABI, the Python wrapper, the MCP tool, the doc sync and the test coverage are clean and consistent, and the EQ_READ fix is correct. Two things before merge, the rest are suggestions.
Before merge
1. EQSAVE still aborts on every record after the first (eq/eqfile.f90:49 is the only WRITE(21,IOSTAT=…); the ~30 WRITE(21) at :56-84 and the CLOSE(21) have no IOSTAT/ERR). ENOSPC, quota or EIO mid-file is a gfortran runtime error that ends the host process (pytest, the MCP server) and leaves a truncated file that EQRTSK's unguarded READs choke on later. The CHANGELOG, README and the eq_api.h text promise "an error return, not the end of the process"; today that holds for the EACCES-on-first-record case only. The least invasive shape is one ERR= label on every WRITE(21…) and the CLOSE that does CLOSE(21) + IERR_OUT=9 (or IOSTAT=IST on each with the same check). A /dev/full test (skip when absent) would pin it.
2. Keep the zero-argument EQSAVE as a compatibility entry. eqfile.f90, eqmenu.f90 and tr/f77/trfile.f are upstream TASK Fortran, and the fork rule is that changes to the eq/tr Fortran body go to the lead for sign-off; the C ABI, wrappers, registries, tests and docs do not. If the status-returning routine gets its own name (say EQSAVE_IERR(IERR_OUT)) and the old SUBROUTINE EQSAVE becomes a two-line wrapper that calls it and drops the code, eqmenu.f90 and trfile.f stay untouched and the upstream-visible diff shrinks to one file (plus the EQ_READ change, which is a genuine bug fix and should be shown to the lead either way). I will raise the sign-off question with @k-yoshimi; this item makes that conversation smaller.
Suggestions (not blocking; say which you take, the rest I will track)
g_has_equilibriumis bypassed by sibling modules in the mono image (libtotapi_mono.so,MONO_LIB_PATH): tot'seq:namespace callseq_param_set/eq_param_set_strdirectly (tot/tot_param_registry.f90:150,:223),tr_init/fp_init/ti_initcalleq_init(tr/tr_api.f90:94,fp/fp_api.f90:102,ti/ti_api.f90:106), and the tr/fp run paths calleq_load/eq_calc(tr/trmetric.f90:41,55,tr/trloop.f90:80,fp/fpprep.f90:66). After any of these the flag is still.TRUE.andeq_savewrites whatever the fields hold. Standalonelibeqapi.sois not affected. This is the same design class as #253 (tot'str:setter bypassing tr's prepared flag), so I would be content with one sentence ineq_api.hand the README ("the flag tracks the eq_api entry points; in the mono image sibling modules can change the equilibrium without clearing it") and I will widen #253 to cover eq. If you prefer to fix it here, an invalidation hook that the registry setters call is the natural place.- Mono image:
eq_saveis refused aftertot_inituntilMODEFWis set to 0.tot_api_initends inpl_init, which setsMODEFW=5(pl/plinit.f90:461); onlyeq_initsets it to 0 (eq/eqinit.f90:368). It fails safe (no unit-5 read), but worth a line in the docs. - MCP
saveoverwrites any writable path by default (MODEFW=0). For an agent-facing tool anoverwrite: bool = Falseargument that maps toMODEFW=3unless set would be a safer default. Design preference, your call. Eq.savesetsKNAMEQbefore the precondition check (python/eqlib/eqlib.py:341), so afterEQ_ERR_INVALIDthe parameter state has changed even though the filesystem has not (a laterrun(1)loads from the new path). Restore it on failure, or say so in the docstring.bytespaths and non-ASCII paths both end asEqlibInvalidParamErrorfrom_encode_name(the docstring saysTypeErrorfor non-str/non-PathLike, and does not mention the ASCII limit). State the restriction oros.fsdecodedeliberately.eq_api_set_param_strnever clears the flag. That is right forKNAMEQ(it is the save target, andrun(1)sets the flag itself after a successful load); a comment saying that none of the KNAM* strings feeds the solve would make the asymmetry intentional rather than accidental.- Stale text next to the edit:
eq/eq_api.h:19-20still sayseq_run(0)returnsEQ_ERR_NOT_IMPLpending L-4;docs/sphinx/modules/eq/{en,ja}/parameters.mdstill listsMODEFR/MODEFWas "(internal)" whileserver.pynow documents them as the FROPEN/FWOPEN modes thatsavedepends on. python/eqlib/tests/test_eqlib.py:198:eq.save("/tmp/eq.bin")is a dead path (the closed-handle check raises first); a relative name avoids the/tmpliteral.
Verified OK
- Only
eq/Makefilecompileseqfile.f90; the two updated call sites are the onlyEQSAVEcallers (case-insensitive grep), and nothing buildstr/f77/, so the CHANGELOG sentence holds. - Implicit typing of
IERR_OUT/IST/IERRis INTEGER; unit 21 is closed on every failure path that exists today;MODEFW ∈ {0,3}matchesFWOPEN(MOD(MODEP,4) ∈ {1,2}orMODEP ≥ 4read unit 5);INQUIRE SIZE=-1is caught by<= 0. EQ_READ:EQDSKRreturns FROPEN's 7 on a missing file and used to be overwritten byEQCALQ; the new order is right andeq_loadalready checks the code beforeeq_bpsd_init/eqcalq.- All "11 → 12" tool counts updated; "six + eq_validate + eq_save" (design.md) and "eight" (eq_api.f90 header) agree;
api-reference.mdautoclass picks upsave. - Local: fresh clone of this branch + unbuilt bpsd clone,
make -C eq libeqapi.so→ OK;EQLIB_PATH=… pytest python/eqlib/tests/{test_save,test_load_errors,test_eqlib}.py python/mcp-servers/eq_mcp/tests/{test_save_tool,test_server}.py --forked --timeout=120 --timeout-method=signal→ 88 passed (macOS, gfortran).
Answers the review of 411971c on k-yoshimi#256. Before merge, 1: only the first WRITE of EQSAVE was checked. What happens when a later write is refused was measured with a file size limit in a child process (macOS, gfortran 15; SIGXFSZ ignored, write() returns EFBIG), in a library loaded by Python: - a test routine, one 160 kB record in one transfer: a plain WRITE ends the process ("Fortran runtime error: File too large"), IOSTAT= returns the error. That is the case the review describes; - a test routine, records of 16 kB: IOSTAT is 0 on every WRITE, on FLUSH and on CLOSE, and the file is short; - EQSAVE itself, limits of 16, 1024, 4096 and 20000 bytes over nothing, an empty file and files of 8, 3000 and 45956 bytes (20 cases, on this commit): in 17 no statement reports anything and the file has the limit's length, of the 45956 bytes written; in the three with the 16-byte limit over a file that is not empty, the first WRITE, which had its IOSTAT, returns 27. On 411971c eq_save returned EQ_OK for a new file at each of the four limits. So EQSAVE_IERR does both. Every WRITE after the first and the CLOSE carry ERR=, for an error the run-time reports. And INQUIRE(UNIT=21, SIZE=) before the CLOSE, which gives the length written to the unit, is compared with INQUIRE(FILE=, SIZE=) after it; a shorter file returns 9 (when either length cannot be had, the comparison is left out). With gfortran the two are equal for a regular file that was written, also when a longer or a shorter file is overwritten (checked). The file name is asked of the unit, because with MODEFW 4 to 7 FWOPEN opens the name it read and not KNAMEQ. A device has length 0 and is reported short as well. Not measured: a full disk, and Linux (/dev/full does not exist here; the test for it runs where it does). What is at the path after such a failure is left there (deleting what the path names is not this routine's to decide). It is usually a short file, and it must not be loaded: run(1) with MODELG=3 on a file cut at 4096 bytes ends the process in EQRTSK, whose READs have no IOSTAT. That is outside this pull request; it is the item raised in the reply on k-yoshimi#231. The header, the docstrings and the README say so. Measured too: a save that leaves a short file leaves one descriptor open (+1 per save). Before merge, 2: EQSAVE is argument-less again. The work is in EQSAVE_IERR(IERR_OUT), which eq_save calls; EQSAVE calls it and drops the code. eq/eqmenu.f90 and tr/f77/trfile.f are as on develop. Suggestions taken: - eq_api.h and the README say that the "successful run" flag is what eq_api's own entry points saw, with examples of what bypasses it in the mono image. - The save tool of eq_mcp has overwrite (default false). It refuses an existing file with a message that names the argument (for a path that is not a regular file, a message that says that), and when it saves it sets MODEFW itself, 3 or 0. - Eq.save: the docstring says that KNAMEQ stays at the path once the path is accepted (eqlib has no getter to restore it from), and that a bytes path and a non-ASCII one are refused. - eq_api_set_param_str: a comment on why it leaves the flag alone. - eq_api.h no longer says eq_run(0) is not implemented; MODEFR and MODEFW are described in parameters.md (en, ja); the /tmp literal in test_eqlib.py is a relative name. Not taken: a line that eq_save is refused after tot_init until MODEFW is set. Measured in the mono image of this branch: after tot_init MODEFW is 0 and eq_run(0), eq_save write the file (rc 0). wr_api_init calls EQINIT after pl_init, and EQINIT sets MODEFW to 0. Tests (python/eqlib/tests/test_save.py, eq_mcp/tests/test_save_tool.py): a write refused at 16 and at 4096 bytes is EqlibCalculationFailedError with the "XX EQSAVE" line, the process alive and the file at the limit; a control without a limit; /dev/full where it exists; the argument-less EQSAVE writes the same bytes as eq_save (called with a null pointer, so that an EQSAVE that takes an argument ends the process instead of passing by accident); a bytes path and a non-ASCII path are refused and touch nothing; the tool refuses an existing file by default and leaves it unchanged, replaces it with overwrite, says "not a regular file" for a directory, and FWOPEN's mode 3 holds when a file appears after the tool's look. On the Fortran of 411971c four of these fail: the two refused-write cases (the save reports success) and the two that call EQSAVE without an argument (the process ends). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Both items are in, in one commit on 1. The writes after the first. Every
So on The What is at the path after such a 9 is left there (it may name a device; deleting it is not this routine's decision), and the header, the docstring and the README say that it cannot be taken for the equilibrium and must not be loaded: 2. Suggestions.
Measured. The present tests on the Fortran of |
|
One more input for the |
k-yoshimi
left a comment
There was a problem hiding this comment.
Re-review of ae6463f (in-house + Codex on the delta, plus a fresh-clone build and the eq test suites locally: make -C eq libeqapi.so from a bare checkout → OK; pytest python/eqlib/tests python/mcp-servers/eq_mcp/tests --forked --timeout=120 --timeout-method=signal → 125 passed, 3 skipped on macOS).
Both items are in as asked and verified: every WRITE(21) after the first and the CLOSE carry ERR=, label 900 closes the unit, unit 21 is closed on every path; the length comparison is strictly "file shorter than what the unit wrote", so on a runtime that reports the on-disk size it degrades to a no-op and cannot false-fail; EQSAVE is argument-less again and eq/eqmenu.f90 / tr/f77/trfile.f are identical to develop; EQ_READ is unchanged from the first round. Your MODEFW-after-tot_init rebuttal is right: the last init in tot_api_init is wr_api_init, which calls EQINIT after pl_init, so MODEFW is 0 there; my note came from reading the init list without the wr step. Thanks for taking the other seven.
Approving. Notes for a follow-up or a later commit, none blocking:
handle_savesets MODEFW to 3 / 0 and leaves it there; one clause in the tool docstring that the value persists would do.- The descriptor that a short-file save leaves open (your measurement, now in the docstring) is libgfortran's, not something
EQSAVE_IERRcan release; recorded as a documented limit. eq/eqfile.f90comment: one sentence that the size comparison's usefulness depends on the runtime reporting the unit's written length (another runtime makes it a no-op, not a failure) would save a future reader a measurement.test_save.pyassert getsize == limitassumes one flush at CLOSE (default 128 KiB buffer);<= limitalso holds underGFORTRAN_UNFORMATTED_BUFFER_SIZEoverrides._encode_name's docstring says 79 bytes where the constant, the README and the test say 80; pre-existing ondevelop, not this PR.
For the maintainer: this PR changes eq/eqfile.f90 (new EQSAVE_IERR, EQ_READ error order), which is upstream eq Fortran; the lead should see it, before or after merge per our rule.
develop gained k-yoshimi#256, k-yoshimi#257, k-yoshimi#258, k-yoshimi#259, k-yoshimi#261, k-yoshimi#263 and k-yoshimi#264 since the last merge (f41fe5b). Two conflicts: - CHANGELOG.md: this branch's entry and develop's new entries were added at the same place under "### Added"; both kept. - fp/fp_api.f90, the USE lists of the module: this branch reads NTG1, TVOLR and the six P*T arrays from fpcomm, k-yoshimi#259 imports fp_param_generation; both kept. And one change that no line conflict shows. k-yoshimi#259 replaced the clear of g_prepared in fp_api_set_param by a count of registry sets (fp_param_generation, compared by fp_run with g_prep_generation). The global scalars of fp_get_state were gated on g_prepared alone, so after the merge a set no longer zeroed them, against what this branch documents and tests ("zero ... and again after a set_param until the next run that is made"). The gate is now "prepared, and no set since the last prep" (g_prepared .AND. g_prep_generation == fp_param_generation()), which also covers sets through tot's "fp:" dispatcher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
develop gained k-yoshimi#242, k-yoshimi#243, k-yoshimi#248, k-yoshimi#249, k-yoshimi#251, k-yoshimi#254, k-yoshimi#255, k-yoshimi#256, k-yoshimi#257, k-yoshimi#258, k-yoshimi#259, k-yoshimi#261, k-yoshimi#263 and k-yoshimi#264 since this branch left it. One conflict, in python/eqlib/_ffi.py, python/fplib/_ffi.py and python/trlib/_ffi.py alike: the end of load_library. k-yoshimi#261 (k-yoshimi#246) checks the state layout of the library just loaded and raises <Mod>libAbiError on a mismatch; this branch closes the directory it registered with os.add_dll_directory when the load fails. The check is now inside the block that cleans up, so a library refused for its layout does not leave its directory in the process-wide DLL search set either (or an entry in _DLL_DIR_HANDLES). Order as on develop: the layout check, then the prototypes. With the check on the load path, the cases of test_platform_libname.py that fake the loaded library (ctypes.CDLL patched) were refused by it: 24 of 39 failed. Each of the three files now has an autouse fixture that patches check_state_layout out for them (the check has its own cases in test_ffi.py, TestLibraryLayout), and one new case per module puts a check back that raises AbiMismatchError (with the prototypes passing, so that only the check can refuse the load) and asserts <Mod>libAbiError, the directory handle closed and nothing registered; with the check moved after the cleanup block, that case fails (measured on eq). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
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>
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 first of the three pieces split out of #226, as asked there on 2026-10-13:
eq_savewith its error reporting, the header and the docstrings, so that the contract ships with the code.develophas noeq_saveyet (it is on #226's branch only), so this pull request brings the feature together with its error reporting rather than the error reporting alone. Reviewing it turned up four more ways in which a save could go wrong without saying so; they are closed here foreq's own entry points and listed below.What is added
eq_save()in the C ABI (eq/eq_api.f90,eq/eq_api.h): writes the equilibrium of the lasteq_runto the TASK/EQ binary file named byKNAMEQ, throughEQSAVE.Eq.save(path)ineqlib: setsKNAMEQ, callseq_save.savetool ofeq_mcp(12 tools now).The file is the one
MODELG=3withKNAMEQloads, in EQ and in the modules that read an equilibrium file (it is what #231'sset_param_str("KNAMEQ", ...)for FP is for).A save that wrote nothing is an error (#227 item 3)
EQSAVEhas an argument now,IERR_OUT: 0 when the file was opened and written,FWOPEN'sIERRwhen it refused the file, 9 when the file was opened and could not be written. It returned silently on a refused open before.eq_savereturns:EQ_ERR_NOT_INITeq_initEQ_ERR_INVALID, no file touchedeq_runsinceeq_init, a later solve or load that failed, oreq_set_paramof anything butMODEFRandMODEFWsince); a blankKNAMEQ;MODEFWother than 0 or 3EQ_ERR_CALC_FAILEDEQSAVEcould not open the file (a directory that is missing or not writable;MODEFW=3and an existing file) or not write it (an existing file without write permission); or no non-empty file exists afterwardsEQ_OKLooking for the file alone is not enough: a save to a path that already holds an earlier file would pass for a success (the repeat-save case of the 2026-07-26 exchange).
Four things beyond that exchange, each measured before it was changed:
An existing file without write permission ended the process.
FWOPENopens an existing file withSTATUS='OLD'and noACTION, so gfortran opens a read-only file for reading andFWOPENreturns 0; the firstWRITEofEQSAVEthen stopped the program (Fortran runtime error: Cannot write to file opened for READ, exit status 2). ThatWRITEhas anIOSTATnow and the case isIERR_OUT = 9.MODEFW1, 2 and 4 to 7 can read unit 5. With 1 or 2FWOPENasks Y/N before it overwrites an existing file, with 4 to 7 it asks for the file name. Unit 5 of a stdio server is the JSON-RPC pipe: measured withMODEFW=4, the save returned success, left the named file as it was and wrote a file called after the next JSON-RPC line.eq_savetakes 0 (overwrite) and 3 (refuse an existing file) only.eq_mcpdescribedMODEFRandMODEFWto its clients as "frequency-space mode switch" and "wave-frequency mode switch"; they are the file modes ofFROPENandFWOPENand say so now.A save of something that is not an equilibrium. Before any run, a save returned success and wrote 45956 bytes that stop the process that loads them (
XX setup_psig: SPL2D ERROR, then theSTOPofeq/eqsub.f90). After a grid count raised following a run (NRGMAXandNZGMAXto 65) it wrote 96644 bytes that end the loading process the same way, sinceEQSAVEwrites the counts as they stand over fields computed for the old ones. After a run that failed following a good one it wrote whatever the failed iteration had left, and returned success.eq_savetherefore keeps a flag, set by a successfuleq_runand cleared when aneq_runstarts and byeq_set_paramof anything but the two file modes, and refuses to save without it. This is stricter than Reconcile bpsi/develop base into develop (bpsi↔fork merge baseline, CI-green) #226'seq_save; say if you would rather have only the first case.eq_run(1)reported a failed load as a success, or not at all. The check above rests on the run's return code.EQ_READcalledEQCALQafterEQDSKRhad failed and handed backEQCALQ's code, and leftIERRunassigned for aMODELGthat is no file format: ondevelop, in a build with run-time checks, the load of a missing EQDSK file (MODELG=5) ends the process at aSTOPwith exit status 0.EQ_READnow starts fromIERR=0, callsEQCALQonly after a successfulEQDSKR, and returns 1 for an unknownMODELG: three lines, the same ones the downstream product line has carried since September.In
eqlib:Eq.savetakes astror anos.PathLike; it refuses, withEqlibInvalidParamError, a path longer than the 80 bytes ofKNAMEQand a path that ends in a blank (Fortran drops the blank:save("result ")wroteresult).The other call sites of
EQSAVEpass a variable: theScommand of theeqmenu (a read-only target no longer ends the program there either), andtr/f77/trfile.f, a file no Makefile builds, whoseCALL EQSAVE(4)fitted neither the old routine nor the new one.Tests
python/eqlib/tests/test_save.py, 20 cases in a temporary directory with relative names (KNAMEQholds 80 characters): the saved file loads back withMODELG=3and gives the grids and the two profiles it was saved with, exactly, and five scalars that are recomputed after the load, to 1e-9; apathlibpath 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 byMODEFW=3; a read-only file;MODEFW1, 2, 4, 5, 6, 7.python/eqlib/tests/test_load_errors.py, 3: the load of a missing file withMODELG3 and 5, and with aMODELGthat is no file format, isEqlibCalculationFailedError. Againstdevelop's library theMODELG=5case does not fail: it ends its process at theSTOP(and under--forkedthe session with it); theMODELG=2case passes there on this machine, where the unassigned code happened to be nonzero.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_savebeforeeq_init.make -C eq eq_api_check_alldoes not link in a build without the graphics library (ondevelopneither, and CI does not run it); built againstlibeqapi.sodirectly, the test passes.With a fault put in and the library rebuilt:
eq_saveignoresEQSAVE's argumentEQSAVE's firstWRITEwithoutIOSTATMODEFWcheckMODEFWcasesOne real stdio session (
initialize,init,save,run(mode=0),save,saveinto 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 (developa0b58408: 773 passed). The 35 skips are the 19 Linux-only equivalence tests, 13 that need the mono image orTOTLIB_PATH, and 3 without atifixture; with the mono image, the threetest_mono_*files,python/eqlibandeq_mcp: 130 passed, 2 skipped. Linux CI is the measurement of the equivalence tests. On the solver's side the change isEQSAVE's argument, theIOSTATof its firstWRITEand the three lines ofEQ_READ, none of which a successful run reaches differently; the flag lives ineq_api.f90. Measured againstdevelop: the state after a run is bit-identical for the two EQ fixtures that load a file and the analytic default (512 values each), and fortr_tst2,tr_iter01,tr_m0904, the TR defaults and a current ramp.Not changed, and known
EQSAVEwritesPSI,DELPSIandHJTas(NSG,NTG)blocks where the arrays are(NTG,NSG)and the reader reads them so: the same bytes whenNSGMAX = NTGMAX(the default, 32 and 32), transposed extents otherwise. S. Terasaki reported it on Reconcile bpsi/develop base into develop (bpsi↔fork merge baseline, CI-green) #226. It is older than this change, and a fix alters the file for unequal grids, so I left it for its own pull request; say if you would rather have it here.WRITEofEQSAVEhas anIOSTAT. A failure later in the file (a full disk) still ends the program.MODEFR=1makes the load path ask for a file name on unit 5 in the same way. Not touched.eq_api's own: set byeq_run, cleared byeq_runandeq_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'seq:names call the registry directly, and in the mono image the other modules' setters write the sharedplcomm(measured there:eq_run(0),tot_set_param("eq:NRGMAX", 65),eq_savereturnsEQ_OK). Moving the mark intoeq_param_registrywould covertot's route; say if you want that here.EQ_READhas other callers:eq_load(tr,fp,wr,wrx),EQLOAD(ob,wm,wmx) and theLandKcommands of theeqmenu.tr,fp,wr,wrx,wmandwmxask forMODELG3, 5 or 8, where a successful load is as before (KsetsMODELG=5itself). TheLcommand passes on whateverMODELGis set (2 as 3), andob/obprep.f90calls it for any: for aMODELGthat is no file format they now getIERR=1.EQSAVE's messages 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 Reconcile bpsi/develop base into develop (bpsi↔fork merge baseline, CI-green) #226, is not here. It can follow as a fourth piece or stay with Reconcile bpsi/develop base into develop (bpsi↔fork merge baseline, CI-green) #226, as you prefer.Scope
Based on
develop(a0b58408). It merges with the companion pull request for the stdio isolation (#257), with #231, #232, #233 and #240, without a conflict (trial merge of all of them together); it sharespython/mcp-servers/eq_mcp/server.pywith the companion andpython/eqlib/_ffi.pywith #240, in different hunks. #226 will takedevelop's version of these files when it is brought up to date.🤖 Generated with Claude Code