Skip to content

feat(fp): expose global plasma scalars (Ip, stored energy, powers, volume) in fp_state_t - #233

Open
HengyuLi-Ozaki-lab wants to merge 1 commit into
k-yoshimi:developfrom
HengyuLi-Ozaki-lab:pr/fp-state-global-scalars
Open

feat(fp): expose global plasma scalars (Ip, stored energy, powers, volume) in fp_state_t#233
HengyuLi-Ozaki-lab wants to merge 1 commit into
k-yoshimi:developfrom
HengyuLi-Ozaki-lab:pr/fp-state-global-scalars

Conversation

@HengyuLi-Ozaki-lab

Copy link
Copy Markdown
Collaborator

Summary

Adds the seven geometry-dependent global plasma scalars that FP already computes to fp_state_t, exposed as a TR-style scalars block:

field unit Fortran source
TOTAL_IP MA Σ_NSA PIT(NSA,NTG1)fpsave.f90:110,146,286, printed :311
STORED_ENERGY MJ Σ_NSA PWT(NSA,NTG1)fpsave.f90:112, printed in the W column :252
COLLISION_POWER MW Σ_NSA PPCT(NSA,NTG1)fpsave.f90:114,278, printed :307
ABSORPTION_POWER MW Σ_NSA PPWT(NSA,NTG1)fpsave.f90:115,277, printed :305
ABSORPTION_WR MW Σ_NSA PWRT(NSA,NTG1)fpsave.f90:284, same print line
ABSORPTION_WM MW Σ_NSA PWMT(NSA,NTG1)fpsave.f90:285, same print line
PLASMA_VOLUME TVOLRfpprep.f90:231-234, banner :237

All seven were already available as fpcomm module data (PIT/PWT/PPCT/PPWT/PWRT/PWMT are (NSAMAX, NTG1M) allocatables; TVOLR is a scalar). None is computed at print time, so all are retrievable after fp_run. The MW/MA conversions are the ones already applied in the moment integrators (fpsave.f90:1310, :1377, :1509, :1510).

Motivation

FP's get_state currently returns only the six per-species profile arrays and the mesh dimensions. Those six are local per-unit-volume moments: with MODELD=0 and no wave or current-drive model, each radial cell is an independent 0-D collisional relaxation depending on local n, T, Z and E — not on B, R or ε.

The practical consequence: loading a completely different equilibrium changes nothing observable. Running fp_iter01 against an analytic equilibrium versus against an eq.bin written by eq's save tool with substantially perturbed geometry (RR 3.0→3.6, RA 1.0→1.2, BB 3.0→4.5, RIP 1.0→2.0, RKAP 1.5→1.8) produces bit-identical RNT/RWT/RTT/RJT/RPCT/RPWT.

The equilibrium is being consumed — eqgetb writes the loaded geometry back into FP, the mesh metrics move (rsrhon 0.805→1.0264, bpm 0.3506→0.1829, ql 2.2961→7.0134), and the global integrals scale with the volume ratio — but it enters only through the volume element and the mesh metrics, and fp_state_t exposed neither. So an MCP/library client driving the MODELG=3 equilibrium-load path had no way to tell whether the file had been used at all.

(Ruled out as a stale-state artifact: changing PTPR/PTPP, PN or NTMAX does move the arrays. Two further attempts to surface a difference — a strong loop voltage driving RJT, and radial transport with MODELD=1 — were also bit-identical, with a control confirming the perturbed eq.bin had been loaded in that arm.)

Why a scalars block

trlib's TrState.to_dict() already returns a top-level "scalars" key; fplib's did not. Beyond exposing the new values, this removes that shape inconsistency — downstream code that reads state["scalars"] generically (for example steady-state detection that looks up a named scalar) silently got None for FP.

Verification

Rebuilt with the repo's current make.header (-fbounds-check -fcheck=all, unmodified).

1 — values agree with the Fortran's own output. Each scalar was compared against the WRITE(6,...) lines of the same run at full precision: TVOLR 0.284245E+02, W 5.3683E+00, collision -2.1308E-01, IP 1.4970E-13, absorption 0.0000E+00. This simultaneously validates the unit conversions and the struct layout — a layout error would read garbage. A supplementary probe with E0=0.05 drove TOTAL_IP to 45.24848, matching the printed 4.5248E+01.

2 — the equilibrium is now observable, and the ratios reproduce what the Fortran diagnostics independently showed:

scalar analytic equilibrium loaded eq.bin (perturbed) ratio
PLASMA_VOLUME 28.42446 53.12014 1.8688
STORED_ENERGY 5.368302 10.33949 1.9260
COLLISION_POWER −0.2130797 −0.4073574 1.9118

The six profile arrays remain bit-identical between those two runs — i.e. the underlying physics is unchanged and it is the new scalars that make the difference visible.

3 — no regression. State captured with the pre-change .so and with the rebuilt one is bit-identical across all six arrays, the five integer dimensions and TIMEFP. Purely additive.

4 — initialisation safety. After init without run, all seven scalars read 0 (the g_prepared guard prevents an uninitialised TVOLR from leaking); after run, they are populated. Filling is bounds-guarded with MIN(NSAMAX, SIZE(PIT,1)) / MIN(NTG1, SIZE(PIT,2)).

5 — tests. fplib 47 passed (was 45), fp_mcp 46 passed (was 45). New coverage asserts the seven fields are appended last in FpStateC._fields_ and are all c_double, that from_c/to_dict carry them, and that the MCP STATE_SCHEMA stays in sync with SCALAR_FIELDS.

6 — determinism. 5/5 fresh runs of each arm, byte-identical outputs.

Honest caveats

  • ABSORPTION_POWER / ABSORPTION_WR / ABSORPTION_WM are verified only at zero. I could not drive them non-zero through the C ABI: MODEL_WAVE=1 with PABS_LH=1.0 still yields 0, and the LH/EC resonance-mesh parameters (DELNPR, NPRMAX, …) are not in fp_param_registry.f90, so they are unreachable from this path. The summation follows exactly the same pattern as the two power fields that are verified against non-trivial values, but a reviewer with a working wave configuration should sanity-check them.
  • ABI change. The seven fields are appended at the end of fp_state_t, so libfpapi.so must be rebuilt; python/fplib/_ffi.py is updated in lockstep.
  • test_equivalence.py::test_iter01 fails on this branch — pre-existing, not introduced here. Verified by stashing the entire change, rebuilding, and re-running: the same failure appears with 37 mismatches, all in profile[*].RPCT at rel_err ~1e-9–1e-10. Structurally it cannot be affected either: _to_baseline_shape() reads only the six arrays, the integer dimensions and TIMEFP, never the new scalars block.

Files

fp/fp_state.f90, fp/fp_api.f90, fp/fp_api.h, python/fplib/_ffi.py, python/fplib/state.py, python/mcp-servers/fp_mcp/server.py, plus tests in python/fplib/tests/ and python/mcp-servers/fp_mcp/tests/.

Independent of the two companion fp_mcp PRs (#231 set_param_str, #232 stdout guard); this one may need a trivial rebase if #231 lands first, since both touch fp_mcp/server.py in different places.

🤖 Generated with Claude Code

…lume) in fp_state_t

Motivation
----------
`fp_get_state` returned only six per-unit-volume profile arrays
(RNT/RWT/RTT/RJT/RPCT/RPWT) plus mesh sizes. In the common configuration
(MODELD=0, no wave model, no current drive) those moments are computed
grid-point-locally, so the equilibrium enters the physics *only* through
the volume element VOLR and the grid metric -- neither of which was in
the state. Measured on the fp_iter01 fixture, swapping the analytic
equilibrium for an EQ-produced eq.bin with a very different geometry
(RR 3.0->3.6, RA 1.0->1.2, BB 3.0->4.5, RIP 2.0, RKAP 1.8) leaves all
six arrays **bit-for-bit identical**, while the underlying plasma volume
nearly doubles. EQ->FP handoff therefore worked in the library but was a
silent no-op to any MCP client.

The quantities that *do* respond were already computed by FPWRTGLB --
they were just WRITE(6,...)'d and thrown away. This commit routes them
into the state struct.

New fields (all species-summed at the latest NTG1 sample)
--------------------------------------------------------
  TOTAL_IP         [MA]  sum PIT;  PIT = sum_NR RJS*VOLR/(2*pi*RR).
                         RJS carries the *1.D-6 A->MA factor at
                         fp/fpsave.f90:1310; printed at fpsave.f90:311.
  STORED_ENERGY    [MJ]  sum PWT;  PWT = sum_NR RWS*VOLR.
                         RWS is *1.D-6 J->MJ at fp/fpsave.f90:1377;
                         printed as the W column of fpsave.f90:252.
  COLLISION_POWER  [MW]  sum PPCT; RPCS *1.D-6 at fpsave.f90:1509,
                         printed at fpsave.f90:307.
  ABSORPTION_POWER [MW]  sum PPWT; RPWS *1.D-6 at fpsave.f90:1510,
                         printed at fpsave.f90:305.
  ABSORPTION_WR    [MW]  sum PWRT -- the "WR:" term of fpsave.f90:305.
  ABSORPTION_WM    [MW]  sum PWMT -- the "WM:" term of fpsave.f90:305.
  PLASMA_VOLUME    [m^3] TVOLR = sum_NR VOLR, fp/fpprep.f90:231-234,
                         printed in the DEVICE banner at fpprep.f90:237.

All seven are zero until the first run: TVOLR and the P*T(NSA,NTG1)
accumulators only exist after fp_prep, and only carry a sample once
FPSGLB has bumped NTG1 (fp/fploop.f90:200). fp_get_state guards on
g_prepared + NTG1 >= 1 + ALLOCATED so it never exposes uninitialised
memory.

Why a `scalars` block
---------------------
The Python wrapper groups them under a top-level `scalars` key, matching
`trlib.state.TrState.to_dict()`. FP was the odd one out: consumers that
branch on `state.get("scalars")` (e.g. task-web's mcp_bridge
steady-state detection) silently saw None for every fp state. Same shape
now means the same code path works for both modules.

ABI
---
The 7 doubles are APPENDED to fp_state_t, so every pre-existing member
keeps its offset. libfpapi.so MUST be rebuilt (`make -C fp libfpapi.so`)
-- a stale .so read through the longer ctypes layout returns garbage in
the trailing members. Three declarations are kept in lockstep:
fp/fp_state.f90, fp/fp_api.h, python/fplib/_ffi.py::FpStateC; a new test
asserts the scalars are the last 7 fields and all c_double.

Verification (fp_iter01 fixture, NTMAX=2)
-----------------------------------------
                    G1 analytic     G2b eq.bin      ratio
  PLASMA_VOLUME       28.42446       53.12014      1.8688
  STORED_ENERGY        5.368302      10.33949      1.9260
  COLLISION_POWER     -0.2130797     -0.4073574    1.9118
  TOTAL_IP             1.497e-13      1.635e-13    (noise; no E-field)
  ABSORPTION_*         0              0            (no wave model)

Every value agrees with the corresponding Fortran WRITE(6,...) line of
the same run to full printed precision, which is the correctness check
for both the units and the struct layout. TOTAL_IP was separately
exercised with E0=0.05: state 45.24848 vs printed 4.5248E+01.

The six profile arrays are bit-for-bit unchanged versus a run against
the pre-change .so, confirming this is purely additive and does not
touch the computation path.

Tests: fplib 47 passed, fp_mcp 46 passed. The single
test_equivalence::test_iter01 failure (37 profile[*].RPCT mismatches at
1e-10) is PRE-EXISTING -- reproduced identically on a pristine rebuild
of HEAD, and _to_baseline_shape() does not read the new `scalars` key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant