Status of the codebase at time of writing: development peaked in 2021 and tapered to a last commit in April 2024. Layer 1 (the FFI wrappers) is mature; Layer 2 (orchestration) is partial; Layer 3 (the object model) stalled roughly one-third complete and internally inconsistent. This document is the plan to stabilize, refactor, and continue the work.
- Native libraries: bump to PhreeqcRM/IPhreeqc
3.8.6-17100. Compiled binaries are installed at/usr/local/lib(libphreeqcrm-3.8.6.so,libiphreeqc.so) with headers in/usr/local/include, and source tarballs are in~/download/. Thesimulkade/PhreeqcRMrelease repo will be updated to 3.8.6 later sostartup.mcan download it. - Keep the value-class idiom (
phrm = phrm.RM_...(), reassign-or-lose). No migration to handle classes. This must be documented loudly and guarded, since it is the #1 footgun. - First focus: stabilize the foundation (Milestone 1) before extending features.
Symbol-level diff of the current wrapper against the installed 3.8.6 .so:
- All 119 currently-wrapped
RM_functions still exist in 3.8.6 → no symbol-level breakage. - 26 new non-BMI functions available to wrap, notably the per-reactant initializers
RM_InitialSolutions2Module,RM_InitialEquilibriumPhases2Module,RM_InitialExchanges2Module,RM_InitialSurfaces2Module,RM_InitialGasPhases2Module,RM_InitialSolidSolutions2Module,RM_InitialKinetics2Module, and gettersRM_GetTemperature/GetPressure/GetPorosity/GetViscosity/GetDensityCalculated/GetSaturationCalculated. - 53 BMI functions (
BMIPhreeqcRM) — a standardized Basic Model Interface, available as a modern alternative binding path (Milestone 4, optional).
Signature changes are still possible even where names match; they are only provable by running, which is exactly what the Milestone 1 test harness exists to catch.
Goal: a version-bumped, test-covered, CI-checked, packageable baseline with the known-broken code either fixed or safely guarded. Nothing here adds user-facing features.
- Headers: verified the current curated
libs/RM_interface_C.h/IPhreeqc.hare a valid subset of the 3.8.6 headers — all 119 wrapped functions are byte-for-byte signature-compatible (only whitespace differs on 4 density/saturation protos, and their old symbols persist in the 3.8.6.so). No wrapper changes needed for the bump. New-function prototypes get added to the header in Milestone 4 when those functions are wrapped. - Rewrote
startup.m: collapsed the four duplicated download blocks into oneensure_libraryhelper; added a local-lib fallback (PHREEQCMATLAB_LIB_PATH→/usr/local/lib) that copies the installed.sointolibs/; added alibs/.phreeqc_versionstamp so a version bump auto-refreshes; version pinned via*_VERSIONconstants. - Discovered + solved the libstdc++ / GLIBCXX_3.4.32 issue (MATLAB's bundled libstdc++
tops out at 3.4.30; the modern-GCC 3.8.6 build needs 3.4.32). Added
run_matlab.shlauncher that setsLD_PRELOADto the system libstdc++, plus a preflight warning instartup.m. - Smoke-tested in MATLAB R2026a:
startup→loadlibrary→RM_LoadDatabase→RM_RunString→RM_FindComponentsreturns correct components end to end. - TODO (deferred): checksum verification of downloaded binaries — deferred until the
simulkade/PhreeqcRMrelease is updated to 3.8.6 (the download path isn't exercised yet).
-
@SingleCellResult/SingleCellResult.m— renamed classdefSingleCellResults→ matchingSingleCellResult; rewrote as a minimal loadable placeholder (full fields in M3). -
@Phase/Phase.m:49—phase string→phase_string; also corrected the keyword to the plural PHREEQCEQUILIBRIUM_PHASES. -
@Phase/Phase.m:114-118—selected_output_object()now writescontent(1..4)so all four lines survive. -
@Phase/Phase.m:274-277—read_json()now populatesphase_names(and matchingmoles) instead of the nonexistentcomponents. -
@Gas/Gas.m:41— wrappedpartial_pressure(i)innum2str; guarded the empty-pressurecase and put each GAS_PHASE identifier on its own line. -
PhreeqcSingleCell.mand@SingleCell/SingleCell.m— removed the redundantRM_Create(). - Broad
catchinrun()/run_in_phreeqc()/equilibrate_in_phreeqc()(Solution.m, Phase.m, Surface.m) — now surfaces the realME.messagevia aPhreeqcMatlab:runFailedwarning instead of a generic line. (Full type-stable return-contract redesign deferred to M2.) - Removed the template-boilerplate
method1/Property1from@Exchange,@Kinetics,@PhaseResult,@SingleCellResultso the classes load cleanly. - Verified in MATLAB R2026a: all touched classes construct;
Phase/Gasphreeqc_string,Phase.read_json, and the result classes work; all 4 demo tests (SimpleAdvect, Advect, Species, Gas_m) pass against 3.8.6. - Deferred to M3:
@SingleCell.run()(undefinediph_string, ignoresvarargin) — left non-functional for now; it is completed as part of the M3 object-model work.
- Added
tests/PhreeqcMatlabTest.m(matlab.unittest) with physically-grounded golden values: pure-water pH=7 & μ≈1e-7 (IPhreeqc); gypsum SI=0 / anhydrite SI≈−0.30 at 25 °C (IPhreeqc); gypsum solubility Ca=S≈15 mmol/L + component set (PhreeqcSingleCell); and a deterministicSolution.phreeqc_string()well-formedness check. Fixture:tests/fixtures/gypsum_anhydrite.pqc. - Single entry point
tests/run_all_tests.m(runtests+ errors on any failure, for CI). All 4 tests pass against 3.8.6. - The demo scripts (
SimpleAdvect,Advect,Species,Gas_m) remain as examples;runtestsignores them (not test classes). - Findings for M2 (robustness):
GetConcentrationssegfaults if called before the module is initialized (RM_InitialPhreeqc2Module+RM_RunCells) — the wrapper should guard the precondition rather than pass an unbacked buffer to the native side. AlsoGetComponentsreturns a column cell array (orientation matters for callers).
-
package_toolbox.mbuildsPhreeqcMatlab.mltbxviamatlab.addons.toolbox.ToolboxOptions(no hand-written.prjneeded). Validated locally: 3.8 MB, ships source + databases + C headers + tests + examples; native binaries are fetched bystartup.m, not bundled. -
CHANGELOG.mdadded (Keep-a-Changelog style). - CI (GitHub Actions) intentionally dropped per maintainer preference; tests are run locally via
./run_matlab.sh -batch "addpath('tests'); run_all_tests".
Goal: make Layer 3 consistent and robust so the remaining classes can be completed cheaply. This is the highest-leverage refactor; do it before finishing individual classes.
- Abstract
Reactantbase class — DONE.src/@Reactant/Reactant.mholds the sharedname/numberidentity, an abstractphreeqc_string(), and a concreteinput_string()that returns one assembled Phreeqc string.Solution,Phase,Surface,Gas,Exchange,Kineticsnow subclass it (name/number removed from each).Surfaceoverridesinput_string()to concatenate its three coupled blocks in order.Exchange/Kineticscarry a loud not-implementedphreeqc_string(real bodies land in M3) so they stay instantiable. Covered byreactantPolymorphism(7/7 tests pass). - Unify the run/equilibrate verb. Today
Solutionusesrun()whileSurface/Phase/Gasuseequilibrate_with(). Deferred into M3, whereExchange/Kinetics/Gas/SingleCellare implemented anyway — a sharedequilibrate_with(solution)template onReactantbuilt oninput_string()will land with them. - Shared string-builder utility — DONE. Added
src/Tools/PhreeqcBlock.m, a fluent value builder (kv/kvopt/flag/line) with empty-field suppression, consistent scalar/vector numeric formatting, and deterministic spacing. RefactoredSolution,Gas,Phase,Surface(all 3 sub-blocks), andSelectedOutputphreeqc_string()onto it; removed thepe/density 0malformed lines and the fragilenum2str(vector)calls. Every block round-trips through IPhreeqc with no parse error; covered bystringBuilderandsurfaceStringRoundTriptests. - Centralize the initial-condition vector — DONE (approach a). Added
src/Tools/InitialConditions.mwith named slot constants (SOLUTION…KINETICS), adetect(C)input scan, andvectors(present, nxyz)that builds ic1/ic2/f1 (single-cell 1×7 or multi-cell nxyz×7).PhreeqcSingleCell,InitializePhreeqcAdvection,InitializePhreeqcFVToolnow share it (was three copies of the keyword scan);Solution.run/Surface.equilibrate_withuse the slot constants instead of magic indices. Behavior-preserving, covered by theinitialConditionsHelpertest. (Approach b — wiring the new per-reactantRM_Initial*2Modulefunctions — is left for M4 when those get wrapped.) - Robust result parsing — DONE (Solution; Surface partially). Added
src/Tools/map_value.m(safecontainers.Maplookup with a fallback).Solution.results_from_phreeqcrmnow reads every SELECTED_OUTPUT column viamap_value, so a renamed/absent column yieldsNaNfor that field instead of throwing and discarding the whole result.Surface's EDL charge/potential lookups likewise robustified; its positionalkeys()/values()slicing is explicitly flagged FRAGILE to revisit in M3 with a CD-MUSIC equilibrate reference test (changing it blind is unsafe). While here, fixed two real bugs the now-testedSolution.runpath exposed: a missingRM_FindComponentsbeforeRunCells(segfault) and a block-concatenation regression (ENDmerged with the next keyword) —combine_phreeqc_stringsmade newline-robust.Solution.runnow returns a populatedSolutionResult; covered bysolutionRunResults/mapValueSafeLookup. (Swapping scraped columns for the newRM_GetTemperature/...getters is left for M4.) - Enum classes: use or lose — DONE (deleted).
@solution_units,@phase_units,@exchange_units,@kinetics_units,@sites_units,@edl_layerwere unused dead code and wiring them would have broken thestrcmpistring comparisons /read_jsonstring assignments. Removed (recoverable via git);src/classesand itsaddpathare gone. The unit-number conventions they documented remain in inline comments at theRM_SetUnits*calls. - Unify JSON — DONE. Added
src/Tools/assign_json_fields.m(shared JSON-field→property copier); refactoredSolution,Phase,Surfaceread_jsononto it (only the Composition/MasterSpecies/Reactions expansions stay bespoke). AddedSolution.to_struct+write_json(round-trips; Composition viacontainers.Mapso element names survive) and aSolution.from_json(name[,file])factory. Deleted the deadTools/read_json_ex.mcopy and the emptyDan/HDan/Krakastubs insolutions.json. Covered byjsonRoundTrip.
Milestone 2 complete. 11/11 tests pass.
Goal: bring the half-built classes up to the Solution/Surface standard, on the M2 base class.
-
@Reactantequilibration template — addedic_slot()(each reactant's RM_InitialPhreeqc2Module slot) and a sharedequilibrate_with(solution)/ protectedrun_with_solution()that centralize the PhreeqcRM boilerplate previously duplicated inSolution.run/Surface.equilibrate_with. -
@Exchange— real properties (sites/moles + optional master species, exchange reactions,log_k,dh),phreeqc_string(), three-blockinput_string(),read_json()/from_json(), inheritedequilibrate_with(). Addeddatabase/exchange.json. -
@Kinetics— reaction/rate fields (-m0,-m,-parms,-tol,-steps, RATES block),phreeqc_string(),input_string(),read_json()/from_json(), andequilibrate_in_phreeqc()(IPhreeqc, integrates-steps). Addeddatabase/kinetics.jsonwith the manual calcite rate. -
@Gas— implementedequilibrate_in_phreeqc(), inheritedequilibrate_with(), fixed the brokenselected_output_string(), addedread_json()/from_json()and moveddamp_CO2()/flue_gas()intodatabase/gases.json. -
@Phase—equilibrate_with()returns aPhaseResult(moles, moles transferred, SI) plus aSolutionResult, via one combined SELECTED_OUTPUT. (combine_selected_output()remains a% TBDhelper — not needed by the object model.) -
@SingleCell.run()— name-value constructor; builds the combined phreeqc string from all contained reactants, registers initial conditions per slot,RM_RunCells, returns a populatedSingleCellResult. -
Result classes
@PhaseResult,@SingleCellResult— real parsed-output structures alongside@SolutionResult/@SurfaceResult. -
Surface.equilibrate_withrefactored (was flagged FRAGILE). Root cause found: it never worked, becausecombine_surface_solution_stringandselected_output_stringjoined keyword blocks withstrjoin's default space delimiter (and a bare concatenation), so PHREEQC misparsed theSURFACE_SPECIES/USER_PUNCHblocks — the positionalkeys()/values()slicing was moot. Both now join with newlines; the result parsing reads the orderedGetSelectedOutputHeadings/GetSelectedOutputand selects them_/la_/element column groups by header prefix instead of slicing the alphabetically-sortedcontainers.Map. A CD-MUSIC calcite surface now equilibrates cleanly with seawater (10 surface species, mole fractions summing to 1, EDL charges/potentials populated); pinned bysurfaceEquilibrateCdMusic. Reference models live inexamples/phreeqc/chalk_cd_music/(Wolthers 2008 + Heberling 2011).
Tests: 17/17 pass (6 new — phase/exchange/kinetics/gas equilibration, SingleCell.run, JSON factories).
- Ship the 3.8.6 C header. The committed
libs/RM_interface_C.hwas still 3.7.x, soloadlibraryonly exposed the old API. Replaced with the 3.8.6 header (214 prototypes) + itsirm_dll_export.h(shipped withIRM_DLL_EXPORTempty so MATLAB's thunk compiler can parse it).loadlibrarynow binds 193 functions (was ~119). - Wrap the new non-BMI 3.8.6 functions in
@PhreeqcRM: scalar-field getters (GetTemperature/GetPressure/GetPorosity/GetViscosity/GetDensityCalculated/GetSaturationCalculated),RM_GetCurrentSelectedOutputUserNumber/RM_SetNthSelectedOutput,RM_Get/SetIthConcentration,RM_Get/SetIthSpeciesConcentration,RM_SetDensityUser/RM_SetSaturationUser, and the seven per-reactantRM_Initial*2Moduleinitializers. -
.pqmcustom-input parser —ParsePqmConfigreads both the 1D (cells/shifts) and multi-D (Nx/Ny/Lx/Ly) forms into a config struct;ApplyRmSettingspushes the PhreeqcRM settings.ReadAdvectionFilenow delegates to them (removing its duplicatedsscanfladder and a redundantRM_Create). - Multi-D reactive transport.
InitializePhreeqcFVToolcleaned up (removed the stale "NOT DONE YET" banner and a strayend); newPhreeqcFVToolTransportcouples FVTool advection/diffusion toRM_RunCellsby operator splitting. FVTool is auto-provisioned bystartup.m(cloned intoexternal/FVTool, gitignored) so it works out of the box;fvtool_availablestill guards it and the driver errors helpfully if provisioning failed. 2D example:examples/transport/reactive_transport_2d.{m,pqm,pqc}. Verified end-to-end with FVTool: the 2D CaCl₂-flush / cation-exchange run reproduces the expected behaviour (Na displaced out, Ca breaks through, Cl → inflow) — regression testreactiveTransport2D. - (Deferred) BMI binding path. The 3.8.6 header exposes 64
RM_Bmi*functions; evaluate wrappingBMIPhreeqcRMas a modern alternative to theRM_C interface. Left for later — theRM_interface fully covers current needs.
Tests: 21/21 pass (3 new — .pqm parser 1D/2D, FVTool guard; plus newApi386Getters).
Woven throughout, consolidated here:
- Loudly document the value-class reassign-or-lose idiom and the explicit-destroy requirement
— a prominent "two things that bite everyone" callout in the README, plus the class-header
docs added across M2–M4 and a dedicated section in
docs/architecture.md. - API reference + contributor guide:
docs/object-model.md(the Layer-3Reactantcontract, every definition class, the result classes and JSON templates),docs/architecture.md(the three-layer map and theRM_naming rule), andCONTRIBUTING.md(setup, conventions, and how to add a wrapper / definition class / test). -
README.mdrefreshed: accurate 3.8.6 install/launch (incl.run_matlab.sh), a high-level object-model quickstart, a "running the tests" section, and an up-to-date roadmap pointer (the stale to-do list is gone). -
CLAUDE.mdkept current (base class, JSON path, 3.8.6 header,.pqm/FVTool helpers).
Remaining (owner task, not code): migrate/expand the GitHub wiki that the README links to — the repo docs above are the source material for it.
- M1a + M1b first (bump + fixes) — small, high-confidence, unblocks a trustworthy baseline.
- M1c (tests) immediately after — locks the baseline and validates the bump.
- M1d (CI/packaging) — automates the gate.
- M2 (object-model core refactor) — the pivot that makes M3 cheap.
- M3 (complete classes), then M4 (new features / transport), with M5 continuous.