4.3. Forward And Inversion Workflow Agents#
These agents prepare, run, evaluate, or compare physics-based modelling and
inversion workflows. They split cleanly into three roles: ForwardModelAgent
applies the forward operator in the direction model → data;
InversionPrepAgent, Occam2DAgent, ModEmAgent, and
Mare2DEMAgent write native input files for the inverse direction without
necessarily running a solver; and InversionBackendAgent,
InversionEvaluationAgent, and InversionComparisonAgent run, evaluate,
or compare the recovered model. Every runnable example below uses the same
28-station WILLY L18PLT profile as the rest of this guide
(data/AMT/WILLY_DATA/L18PLT), loaded once with
MTLoaderAgent.
4.3.1. ForwardModelAgent#
ForwardModelAgent applies the forward operator from
pycsamt.forward – 1-D, 2-D (finite-difference TE+TM), or quasi-3-D,
selected by dim – to a resistivity model. Use it for synthetic checks,
sensitivity experiments, or validating how a proposed model should appear in
impedance or apparent-resistivity space before it is ever compared against
field data. The input dictionary takes freqs (Hz), not periods –
passing periods is silently ignored and the agent falls back to its
default 40-point log-spaced band, so double-check the key name if a run
uses fewer frequencies than requested.
>>> import numpy as np
>>> from pycsamt.agents import ForwardModelAgent
>>> result = ForwardModelAgent(dim=1).execute({
... "model": {
... "resistivities": [100.0, 10.0, 1000.0, 100.0],
... "thicknesses": [500.0, 1000.0, 2000.0],
... },
... "freqs": list(np.logspace(-2, 2, 12)),
... "output_dir": "outputs/forward",
... })
>>> print(result.summary)
1-D forward: 4 layers. no observed data. 1 figure(s).
>>> result["rho_a"][:3]
array([75.39399946, 65.4962356 , 53.51079821])
The three panels plot_response_and_model_1d combines: the layered
earth model, its synthetic \(\rho_a\), and its synthetic phase –
the same round trip used as a sanity check throughout
Overview.#
Pass observed sites alongside a 1-D model and the agent also scores
the mismatch, using a simpler statistic than the glossary’s general
RMS misfit: an unweighted log-apparent-resistivity RMS,
interpolated onto the observed period grid and computed with no per-datum error weighting – useful as a coarse “does this model look plausible at all” check, but not interchangeable with the normalised, uncertainty-aware RMS misfit an actual inversion reports. Comparing the arbitrary four-layer model above against the real WILLY profile (unrelated survey, so a large value is expected) gives exactly that kind of number:
>>> from pycsamt.agents import MTLoaderAgent
>>> loaded = MTLoaderAgent().execute({"path": "data/AMT/WILLY_DATA/L18PLT"})
>>> checked = ForwardModelAgent(dim=1).execute({
... "model": {
... "resistivities": [100.0, 10.0, 1000.0, 100.0],
... "thicknesses": [500.0, 1000.0, 2000.0],
... },
... "freqs": list(np.logspace(-2, 2, 12)),
... "sites": loaded["sites"],
... })
>>> print(checked.summary)
1-D forward: 4 layers. RMS 1.151. 1 figure(s).
An RMS of 1.15 here says only that this arbitrary model is roughly the right order of magnitude for the WILLY profile, not that it is a fitted result – treat it exactly as Overview treats a noiseless round-trip check: confirmation the forward path runs correctly, not evidence about the earth.
4.3.2. InversionPrepAgent#
InversionPrepAgent is meant as the general, backend-agnostic
pre-inversion interface – its own module docstring calls it a “Phase 2
stub.” It always validates the Sites object and counts stations and
periods; whether it actually writes a file depends entirely on code.
Run against the real WILLY profile to see the current state honestly:
>>> from pycsamt.agents import MTLoaderAgent, InversionPrepAgent
>>> loaded = MTLoaderAgent().execute({"path": "data/AMT/WILLY_DATA/L18PLT"})
>>> prep = InversionPrepAgent().execute({
... "sites": loaded["sites"],
... "output_dir": "outputs/inversion_prep",
... "period_range": [0.001, 1.0],
... })
>>> print(prep.status)
needs_review
>>> print(prep.summary)
Inversion prep (occam2d): 28 stations, ~39 periods. No data file written.
>>> print(prep.warnings)
['pycsamt.models.occam2d not available. Data file was not written.']
code="occam2d" (the default) currently cannot write a file at all: the
writer function it calls, write_occam2d_data, does not exist in the
installed pycsamt.models.occam2d, so every call falls through to the
ImportError branch above regardless of input. code="modem" is
explicitly stubbed the same way, with its own warning that the ModEM writer
is planned for a future phase. n_stations and n_periods in the
result are still real and useful for a quick survey-scope check, but for
actually writing inversion-ready files today, use
Occam2DAgent, ModEmAgent, or
Mare2DEMAgent directly – each verified working
below.
4.3.3. Occam2DAgent#
Occam2DAgent is the specialized Occam2D preparation agent. It
writes the data, mesh, model, and startup files described in
Occam2D – the same four-file set, regardless of whether
the request goes through this agent or the lower-level writers directly.
Use it after QC, correction, optional rotation, and frequency selection:
>>> from pycsamt.agents import MTLoaderAgent, Occam2DAgent
>>> loaded = MTLoaderAgent().execute({"path": "data/AMT/WILLY_DATA/L18PLT"})
>>> occam = Occam2DAgent().execute({
... "sites": loaded["sites"],
... "output_dir": "outputs/willy_occam2d",
... "run_external": False,
... })
>>> print(occam.summary)
Occam2D prep: 28 stations × 53 periods. 4/4 files written to outputs/willy_occam2d.
>>> sorted(occam.data)
['data_path', 'mesh_path', 'model_path', 'n_data', 'n_periods', 'n_stations', 'output_dir', 'startup_path']
run_external=False stops after writing the four files, so no Occam2D
binary is required to reach this success status; set it to True only
once that binary is installed and the run should actually execute. This is
the same agent, run the same way, already documented in
Agent Catalogue; it is repeated here because forward and inversion
preparation belong to one physics-based chain, and every reader of that
chain should see the exact same numbers in both places.
4.3.4. ModEmAgent#
ModEmAgent prepares ModEM 3-D impedance data files. Use it when
the survey and interpretation target require 3-D inversion products rather
than a 2-D profile. Unlike InversionPrepAgent’s code="modem" path
above, this specialized agent is fully implemented:
>>> from pycsamt.agents import MTLoaderAgent, ModEmAgent
>>> loaded = MTLoaderAgent().execute({"path": "data/AMT/WILLY_DATA/L18PLT"})
>>> modem = ModEmAgent().execute({
... "sites": loaded["sites"],
... "output_dir": "outputs/willy_modem",
... })
>>> print(modem.summary)
ModEM3D prep: 28 stations × 53 periods. 4 file(s) written to outputs/willy_modem.
>>> modem["data_path"].name
'ModEM_Data.dat'
>>> modem["model_path"].name, modem["cov_path"].name, modem["ctrl_path"].name
('m0.ws', 'ModEM.cov', 'ModEM.inv')
The four files – data, starting model, covariance, and control – are
exactly the ones ModEM documents; model_path,
cov_path, and ctrl_path come from a separate InputBuilder step
run automatically after the data file, and only ever come back None if
that step warns rather than errors (check modem.warnings in that case,
since status still reads "success" off the data file alone).
4.3.5. Mare2DEMAgent#
Mare2DEMAgent orchestrates MARE2DEM 2.5-D EM inversion preparation and,
when a compiled binary is available, execution. It is the specialized agent
to use when the target workflow needs MARE2DEM .emdata, .resistivity,
.settings, and run-directory products rather than Occam2D or ModEM files.
The agent can work in three modes:
"prepare"Write input files only. This is the safest default and is appropriate when the run will be launched manually on a workstation or cluster.
"run"Write inputs and launch the configured MARE2DEM binary. Use this only after confirming the binary, MPI configuration, and source tree.
"report"Inspect an existing run directory and summarize result state without writing new inputs or launching the binary.
Typical input keys include emdata for an existing data file, mt or
csem dictionaries for building survey data, optional topo, optional
resistivity, output_dir, source_dir, n_procs,
max_iterations, target_rms, and initial_rho. Build from survey
parameters when there is no .emdata file yet, by passing the MARE2DEM
survey configuration through mt or csem:
>>> import numpy as np
>>> from pycsamt.agents import Mare2DEMAgent
>>> agent = Mare2DEMAgent(n_procs=8)
>>> prepared = agent.execute({
... "mt": {
... "frequencies": list(np.logspace(-3, 3, 20)),
... "rx_y": list(np.linspace(-5000, 5000, 20)),
... "rx_type": "land",
... "lTE": True,
... "lTM": True,
... },
... "topo": 0.0,
... "output_dir": "outputs/mare2dem",
... "mode": "prepare",
... })
>>> print(prepared.summary)
MARE2DEM prepare:. 20 MT receivers. 1600 data points. 3/3 files written. Output: outputs/mare2dem.
>>> prepared["data_path"].name, prepared["settings_path"].name
('mare2dem.emdata', 'mare2dem.settings')
>>> prepared["binary_found"]
False
>>> prepared.warnings
['MARE2DEM binary not found. Set download_source=True to auto-build, or run: SourceManager().download(); SourceManager().build()']
binary_found is False and a warning explains why, but status is
still "success" – mode="prepare" only needs to write the files,
which it did (3/3); reaching that same result with mode="run"
instead would require the compiled MARE2DEM binary this environment does not
have. Once a .emdata file exists, the second and later runs can reuse it
directly instead of rebuilding the survey configuration:
>>> reused = agent.execute({
... "emdata": "outputs/mare2dem/mare2dem.emdata",
... "output_dir": "outputs/mare2dem_rerun",
... "initial_rho": 100.0,
... "target_rms": 1.0,
... })
>>> print(reused.summary)
MARE2DEM prepare:. 20 MT receivers. 1600 data points. 3/3 files written. Output: outputs/mare2dem_rerun.
Use Mare2DEMAgent after QC, static-shift correction, tensor rotation, and
frequency selection. Use InversionEvaluationAgent afterwards when a run
directory contains results that need RMS, convergence, or residual summaries.
4.3.6. InversionBackendAgent#
InversionBackendAgent connects agent workflows to the backend-neutral
pycsamt.inversion API documented in Overview –
builtin, simpeg, pygimli, occam2d, or modem – so a
workflow can select or execute a configured backend rather than only write
preparation files:
1backend = InversionBackendAgent().execute({
2 "sites": processed_sites,
3 "backend": "occam2d",
4 "output_dir": "/out/backend_run",
5})
Passing a real, field-derived Sites object is the agent’s documented
contract, but as of this writing that specific path does not complete for
ordinary field data with the builtin backend, on either dimension:
>>> from pycsamt.agents import MTLoaderAgent, InversionBackendAgent
>>> loaded = MTLoaderAgent().execute({"path": "data/AMT/WILLY_DATA/L18PLT"})
>>> backend = InversionBackendAgent().execute({
... "sites": loaded["sites"],
... "backend": "builtin",
... "dimension": "1d",
... })
>>> print(backend.status)
failed
>>> print(backend.error)
Inversion failed: frequencies must match data sample length.
The failure comes from the internal Sites → EMData adapter, not from
the solver itself: real field EDIs rarely share one common frequency grid
across every station, and that adapter currently expects one. It is worth
knowing precisely because the symptom – a shape-mismatch ValueError –
gives no hint that the fix lives upstream of anything this agent’s own
parameters (n_layers, max_iter, regularization) can control.
Until that adapter is fixed, prefer one of two working paths: write native
input files with Occam2DAgent,
ModEmAgent, or
Mare2DEMAgent (all verified above) and run the
external solver directly; or call pycsamt.inversion yourself with an
explicit freqs/rho_a/phase dictionary the way
Overview does, bypassing the Sites adapter entirely.
backend="occam2d" or "modem" inside this same agent additionally
require the corresponding external binary to be installed, independent of
this data-shape issue.
4.3.7. InversionEvaluationAgent#
InversionEvaluationAgent compares two Sites-shaped collections –
observed data against a model-predicted response – to summarize per-station
RMS, a residual phase tensor table, and a station-response figure.
Its input keys are sites_obs/path_obs and
sites_mod/path_mod, both accepting the same kinds of input as
MTLoaderAgent; it does not accept an inversion
run directory path directly, so a completed Occam2D or ModEM response
first needs loading into a Sites-like object. Its RMS statistic is the
same unweighted log-apparent-resistivity formula
ForwardModelAgent uses above, applied
per-station instead of averaged over one model.
There is no fitted model response available in this environment to compare
against (see the InversionBackendAgent limitation just above), so the
run below compares the WILLY profile with itself – a sanity check on the
mechanics, not a real evaluation, exactly as noted for
ForwardModelAgent’s self-comparison earlier:
>>> from pycsamt.agents import MTLoaderAgent, InversionEvaluationAgent
>>> loaded = MTLoaderAgent().execute({"path": "data/AMT/WILLY_DATA/L18PLT"})
>>> evaluation = InversionEvaluationAgent().execute({
... "sites_obs": loaded["sites"],
... "sites_mod": loaded["sites"],
... "output_dir": "outputs/willy_eval",
... })
>>> print(evaluation.summary)
Inversion evaluation: global RMS = 0.000
>>> list(evaluation["rms_per_station"].items())[:3]
[('18-001A', 0.0), ('18-002U', 0.0), ('18-003A', 0.0)]
plot_station_response plots one representative station – here the
first, 18-001A – across all four impedance components
\(Z_{xx}, Z_{xy}, Z_{yx}, Z_{yy}\). Observed and “model” curves
overlap exactly because both inputs are the same Sites object; a real
evaluation would show the two series diverging where the inversion fit
is weakest.#
In real use, build sites_mod from an actual inversion’s predicted
response – an Occam2D .resp file or a ModEM residual file loaded the
same way sites_obs was – rather than reusing the observed data.
4.3.8. InversionComparisonAgent#
InversionComparisonAgent compares two resistivity sections directly, as
arrays – not as file paths. Its input keys are result_a and
result_b, each either an AgentResult from an
inversion agent or a plain dict carrying a "pred_rho" array shaped
(n_layers, n_stations) (or a "predictions" dict of
{station: log10-resistivity array}). Use it for before/after studies,
backend comparisons, parameter sweeps, or checking whether correction
changed the interpreted structure – for example AI-predicted versus
Occam2D, as its module docstring frames it.
Two synthetic sections stand in below for two real inversion results, offset by a constant so the comparison has something to show:
>>> import numpy as np
>>> from pycsamt.agents import InversionComparisonAgent
>>> rng = np.random.default_rng(0)
>>> n_layers, n_sta = 6, 5
>>> base = np.linspace(1.5, 3.0, n_layers)[:, None] * np.ones((1, n_sta))
>>> model_a = base + rng.normal(0, 0.05, size=(n_layers, n_sta))
>>> model_b = base + 0.3 + rng.normal(0, 0.05, size=(n_layers, n_sta))
>>> comparison = InversionComparisonAgent().execute({
... "result_a": {
... "pred_rho": model_a.tolist(),
... "station_names": [f"S{i:02d}" for i in range(n_sta)],
... },
... "result_b": {"pred_rho": model_b.tolist()},
... "label_a": "Occam2D",
... "label_b": "AI (Inv2DAgent)",
... "output_dir": "outputs/comparison",
... })
>>> print(comparison["correlation"], comparison["rmse"])
0.990313863436974 0.32773465450255923
The two statistics answer different questions. Correlation – \(\rho = \mathrm{corr}(\log_{10}\rho^{A}, \log_{10}\rho^{B})\) over every finite, co-located cell – is high (0.99) because both sections share the same layered shape; RMSE,
is close to the 0.3 offset actually injected, because it is sensitive to a systematic shift that a shape-only correlation would miss entirely – two sections can be nearly perfectly correlated and still disagree everywhere in absolute resistivity. Read both together, not either alone.
Model A, Model B, and their difference \(A-B\) in
\(\log_{10}\rho\). The difference panel is uniformly negative here
because model_b was constructed as model_a plus a constant –
exactly the systematic offset RMSE captured above and correlation alone
would have hidden.#
4.3.9. Typical Physics-Based Chain#
Every agent on this page fits somewhere in one lifecycle, and this is that lifecycle spelled out as a chain – the same shape AgentCoordinator builds explicitly and WorkflowOrchestratorAgent builds from a natural-language request:
MTLoaderAgent -> DataQCAgent -> StaticShiftAgent
-> PhaseAnalysisAgent -> FrequencyDecimationAgent
-> Occam2DAgent or ModEmAgent or Mare2DEMAgent
-> InversionEvaluationAgent
Use InversionComparisonAgent at the end, off to the side of this main
chain, when two or more of its outputs – from different backends,
correction settings, or regularization choices – need to be compared and
documented rather than only evaluated on their own.