3. Transformers#
Every acquisition system ends up writing its own transfer-function file
format, and every one of those formats eventually needs to become an
EDI so the rest of pyCSAMT – QC, static shift, dimensionality,
inversion prep – can treat it the same way. pycsamt.transformers
is where that convergence happens: a AVG file, a J-file, a
raw cross-power-spectra EDI, or a field time series all enter through a
different reader, but each one leaves through the same door, an
EDIFile or an
EDICollection. A fifth converter,
pycsamt.tdem.transform.TEMtoEDI, lives in a different subpackage
because its input physics (a transient decay, not a continuous-wave
transfer function) is genuinely different, but it honours the exact
same contract and is covered here alongside the other four.
3.1. The Transformer Contract#
Every converter in this module is a small subclass of
TransformerMixin, and every subclass moves
data through the same neutral payload,
TFBundle. A bundle just holds frequency-indexed
arrays – freq, z/z_err, tipper/tipper_err,
rho/phase – plus a station name and optional coordinates. Concrete
transformers only have to implement two steps: extract (parse the
foreign format into a bundle) and emit_edi (build an
EDIFile from a finalized one). Everything
in between is handled once, by the mixin’s _finalize:
resolve a valid station identity, synthesizing one from the station id if the source did not provide a usable name;
order frequencies ascending or descending;
drop nearly-duplicate frequencies within a relative tolerance;
optionally fill
zfrom(rho, phase)or vice versa, if the concrete transformer implements the physics for it.
All four of those steps read from one live settings object,
pycsamt.core.config.CoreConfig, so the same policy applies
everywhere a bundle is finalized rather than being repeated per format:
>>> from pycsamt.core.config import get_config, config_context
>>> from pycsamt.core.base import TFBundle, ensure_station
>>> import numpy as np
>>> cfg = get_config()
>>> print(cfg.freq_order, cfg.freq_tol, cfg.compute_res_from_z, cfg.compute_z_from_res)
desc 1e-09 True True
>>> raw = TFBundle(
... freq=np.array([10.0, 10.0000001, 5.0, 20.0]),
... rho=np.array([80.0, 80.0, 120.0, 40.0]),
... phase=np.array([45000.0, 45000.0, 42000.0, 48000.0]),
... station=None,
... station_id=17,
... )
>>> print(raw.is_empty())
False
>>> print(ensure_station(raw.station, raw.station_id))
S017
freq_order='desc' and a freq_tol of one part in a billion are the
project defaults; compute_res_from_z/compute_z_from_res only take
effect when the concrete transformer actually implements
compute_res_from_z/compute_z_from_res – on the bare mixin they are
no-ops, which is worth seeing directly rather than taking on faith:
>>> from pycsamt.transformers import TransformerMixin
>>> class Echo(TransformerMixin):
... def extract(self, source):
... return source
... def emit_edi(self, bundle):
... return bundle
...
>>> with config_context(freq_order="asc", freq_tol=1e-6):
... out = Echo()._finalize(raw, station_id=raw.station_id)
...
>>> print(out.station)
S017
>>> print(out.freq)
[ 5. 10. 20.]
>>> print(out.rho)
[120. 80. 40.]
>>> print(out.z)
None
Three things happened inside that one _finalize call: the synthetic
name S017 from the earlier example survived (a mixin without physics
does not overwrite a name that already validated), the near-duplicate
frequency pair 10.0/10.0000001 collapsed to a single 10.0 under
the tightened freq_tol, and reordering to asc moved the surviving
three frequencies and their matching rho values together. out.z
stays None because Echo never overrode compute_z_from_res –
AVGtoEDI is the first converter below that
actually does.
3.2. Zonge AVG Conversion#
An AVG file stores one instrument-averaged transfer-function record
per frequency, and a Zonge line file can bundle many stations into one
file. data/avg/K1.AVG is a real 47-station CSAMT line; loading it
directly with AVG shows the raw tensor shape
before any EDI construction happens:
>>> import numpy as np
>>> from pycsamt.zonge.avg import AVG
>>> from pycsamt.transformers import AVGtoEDI
>>> avg = AVG.from_file("data/avg/K1.AVG")
>>> z, f, st = avg.to_tensor(var="z")
>>> print(z.shape, f.shape, len(st), st.min(), st.max())
(47, 17, 2, 2) (17,) 47 150.0 2450.0
Every station carries 17 frequencies, and st here is not a station
name but the line chainage in metres – AVGtoEDI turns that numeric
distance into a proper station identity through the same
ensure_station machinery shown above:
>>> col = AVGtoEDI().transform(avg)
>>> print(type(col).__name__, len(col))
EDICollection 47
>>> print([ed.station for ed in col][:3], "...", [ed.station for ed in col][-3:])
['S150', 'S200', 'S250'] ... ['S2350', 'S2400', 'S2450']
>>> ed0 = col[0]
>>> print(ed0.station, ed0.Z.freq.shape, ed0.has_tipper)
S150 (17,) False
>>> print(ed0.Z.freq[:5])
[8192. 4096. 2048. 1024. 512.]
>>> print(np.round(ed0.Z.res_xy[:5], 6))
[0.000438 0.001193 0.002921 0.006704 0.006543]
>>> print(np.round(ed0.Z.res_yx[:5], 6))
[0. 0. 0. 0. 0.]
res_yx is exactly zero, not noisy-small: K1 is a scalar CSAMT line with
only an Ex-Hy receiver pair, the same single-off-diagonal-component
situation already worked through in
Field Zones: Near, Transition, And Far Field. The resistivities themselves look tiny for a
CSAMT line – a few \(\times 10^{-3}\) rather than tens of ohm-metres
– because ed0.Z computes them with the legacy Zonge convention
\(\rho_a = 0.2\,|Z|^2/f\) on field-unit impedances, not the SI
convention; Impedance Tensor derives and names that
~633257x scale gap (RHO_FACTOR/ZONGE_RHO_FACTOR) in full, and it
resurfaces below in the TEM section too.
3.3. Attaching Station Topography#
An AVG object on its own has no coordinates – avg.topo is
None until a matching Zonge .stn file is attached with
add_topography(). K1’s companion file,
data/avg/K1.stn, records station easting/northing in UTM zone 49N
rather than latitude/longitude, which matters for what
AVGtoEDI.post_emit can actually populate:
>>> avg2 = AVG.from_file("data/avg/K1.AVG").add_topography(
... "data/avg/K1.stn", utm_zone="49N",
... )
>>> print(avg2.topo.frame.head(3))
station easting northing elevation
0 150 748846.846 2883860.032 574.5
1 200 748893.155 2883840.178 572.3
2 250 748935.809 2883812.363 561.3
>>> col_bare = AVGtoEDI().transform(avg2)
>>> h0 = col_bare[0].get_section("head")
>>> print(col_bare[0].station, h0.lat, h0.long, h0.elev)
S150 0.0 0.0 574.5
Elevation came through because the elevation column already exists,
but latitude/longitude stayed at the 0.0 placeholder: post_emit
only copies coordinates when it finds columns literally named
latitude/longitude (or a small set of aliases), and easting/northing
do not qualify. Projecting the frame first with
convert_coords() closes that
gap:
>>> avg2.topo.convert_coords(to="ll", inplace=True)
>>> print(avg2.topo.frame[["station", "latitude", "longitude"]].head(3))
station latitude longitude
0 150 26.052401 113.487159
1 200 26.052214 113.487617
2 250 26.051955 113.488038
>>> col_topo = AVGtoEDI().transform(avg2)
>>> h0 = col_topo[0].get_section("head")
>>> hN = col_topo[-1].get_section("head")
>>> print(col_topo[0].station, round(h0.lat, 5), round(h0.long, 5), h0.elev)
S150 26.0524 113.48716 574.5
>>> print(col_topo[-1].station, round(hN.lat, 5), round(hN.long, 5), hN.elev)
S2450 26.04033 113.50579 410.0
The line runs about 2.3 km along strike and drops roughly 160 m in
elevation from its first to its last station – real relief that a later
static-shift or topography-drape step would need to see, which is exactly
why attaching the .stn file before conversion (rather than after) is
worth making a habit.
With coordinates and all 47 stations in hand, the converted line can be plotted directly – three representative soundings side by side with a full along-line pseudosection, reproducing the figure below byte-for-byte:
1import matplotlib.pyplot as plt
2
3picks = ["S150", "S1200", "S2450"]
4fig, axes = plt.subplots(1, 2, figsize=(11.0, 4.2))
5
6ax = axes[0]
7for name in picks:
8 ed = next(e for e in col if e.station == name)
9 ax.loglog(ed.Z.freq, ed.Z.res_xy, "o-", ms=4, lw=1.3, label=name)
10ax.set(xlabel="Frequency (Hz)", ylabel=r"$\rho_{a,xy}$ (field units)",
11 title="K1 line: three stations")
12ax.legend(fontsize=8)
13ax.grid(True, which="both", alpha=0.3)
14
15rho_grid = np.array([e.Z.res_xy for e in col])
16dist = np.array([float(e.station.lstrip("S")) for e in col])
17ax2 = axes[1]
18pc = ax2.pcolormesh(dist, ed0.Z.freq, np.log10(rho_grid).T,
19 shading="auto", cmap="turbo")
20ax2.set_yscale("log")
21ax2.invert_yaxis()
22ax2.set(xlabel="Station chainage (m)", ylabel="Frequency (Hz)",
23 title=r"K1 line: $\log_{10}\rho_{a,xy}$")
24fig.colorbar(pc, ax=ax2, label=r"$\log_{10}\rho_a$")
25fig.tight_layout()
Left: apparent resistivity at three stations spanning the K1 line, all
from the same AVGtoEDI().transform(avg) call. Right: the same
quantity for every station, showing a resistive zone around station
150-750 m and two conductive zones further along the line.#
3.4. Jones J Conversion#
A J-file carries the same kind of transfer function as an AVG file,
but as a plain-text, one-station-per-file ASCII format from the BIRRP
processing lineage. data/j/kb0-s001.txt is a real single-station J-file;
JtoEDI accepts either the path or an
already-parsed JFile and returns a single
EDIFile rather than a collection, because one
J-file is one station:
>>> from pycsamt.jones.j import JFile
>>> from pycsamt.transformers import JtoEDI
>>> jf = JFile.from_file("data/j/kb0-s001.txt")
>>> print(jf.station, jf.n_freq, jf.lat, jf.lon, jf.azimuth)
KB0001 29 41.9782 140.8958 330.0
>>> ed_j = JtoEDI().transform(jf)
>>> print(type(ed_j).__name__, ed_j.station, ed_j.Z.freq.shape, ed_j.has_tipper)
EDIFile KB0001 (29,) True
>>> print(ed_j.Z.freq[:6])
[10320. 7020. 270. 256. 192. 128.]
>>> print(np.round(ed_j.Z.res_xy[:6], 6))
[1.39e-04 1.46e-04 7.90e-05 8.60e-05 6.40e-05 4.50e-05]
>>> print(np.round(ed_j.Z.res_yx[:6], 6))
[2.31e-04 2.00e-04 1.56e-04 1.79e-04 8.20e-05 6.80e-05]
Unlike the K1 scalar line, KB0001 has both off-diagonal components
populated and a real tipper, so has_tipper is True. The
station-naming path is worth a second look here: JFile already carries
a valid name (KB0001), so ensure_station keeps it untouched rather
than synthesizing one – the same rule demonstrated on the bare mixin
earlier now runs for real.
JtoEDI also accepts a whole JCollection
and returns an EDICollection in that case,
mirroring AVGtoEDI’s multi-station behaviour. data/j/nia holds four
J-files that pyCSAMT itself exported from the K1 line – the first
station’s coordinates match K1.stn’s 26.0524, 113.48716 exactly,
confirming the two directories describe the same physical line in two
different formats:
>>> from pycsamt.jones.collection import JCollection
>>> jc = JCollection.from_sources("data/j/nia", recursive=False)
>>> print(len(jc), sorted(jc.stations()))
4 ['NIA000', 'NIA001', 'NIA002', 'NIA003']
>>> col_j = JtoEDI().transform(jc)
>>> print(type(col_j).__name__, len(col_j))
EDICollection 4
>>> print(sorted(ed.station for ed in col_j))
['NIA000', 'NIA001', 'NIA002', 'NIA003']
The single-station conversion above is enough to show what a converted J-file looks like end to end – both off-diagonal apparent-resistivity curves together with their phases, reproducing the figure below:
1fig, axes = plt.subplots(1, 2, figsize=(11.0, 4.2))
2per = 1.0 / ed_j.Z.freq
3
4ax = axes[0]
5ax.loglog(per, ed_j.Z.res_xy, "o-", color="#1f77b4", ms=4, label=r"$\rho_{a,xy}$")
6ax.loglog(per, ed_j.Z.res_yx, "s-", color="#d62728", ms=4, label=r"$\rho_{a,yx}$")
7ax.set(xlabel="Period (s)", ylabel=r"$\rho_a$ (field units)",
8 title=f"J-file conversion ({ed_j.station})")
9ax.legend(fontsize=8)
10ax.grid(True, which="both", alpha=0.3)
11
12ax2 = axes[1]
13ax2.semilogx(per, ed_j.Z.phase_xy, "o-", color="#1f77b4", ms=4, label=r"$\phi_{xy}$")
14ax2.semilogx(per, ed_j.Z.phase_yx, "s-", color="#d62728", ms=4, label=r"$\phi_{yx}$")
15ax2.set(xlabel="Period (s)", ylabel="Phase (deg)", title="Phase")
16ax2.legend(fontsize=8)
17ax2.grid(True, alpha=0.3)
18fig.tight_layout()
KB0001’s two off-diagonal modes disagree by up to half a decade in
resistivity at short period and cross over near 1 s – real 2-D/3-D
behaviour rather than the clean antisymmetric pair a 1-D earth would
produce.#
3.5. Spectra-EDI Conversion#
Some instruments export an EDI whose >=SPECTRASECT block holds raw
cross-power spectrum estimates instead of a finished impedance
tensor – richer, but one processing step short of usable Z.
SpectraToEDI closes that last step. For each
frequency it forms the electric-magnetic and vertical-magnetic blocks and
solves
where \(\mathbf{S}_{HH}\) is the magnetic auto-spectral block and
\(\mathbf{S}_{EH}\)/\(\mathbf{S}_{ZH}\) are the electric- and
vertical-magnetic cross-spectral blocks. data/MT/SPECTRA/spectra01.edi
is a de-identified short-period field example with a vertical channel, so
both Z and the tipper come out of one call:
>>> from pycsamt.transformers import SpectraToEDI
>>> col_sp = SpectraToEDI(estimate_error=True).transform(
... "data/MT/SPECTRA/spectra01.edi",
... )
>>> ed_sp = col_sp[0]
>>> print(ed_sp.station, ed_sp.Z.n_freq, ed_sp.has_tipper)
SPECTRA01 51 True
>>> print(ed_sp.Z.freq[:5])
[10400. 8800. 7200. 6000. 5200.]
>>> print(np.round(ed_sp.Z.res_xy[:5], 3))
[119.759 80.02 28.197 20.613 20.13 ]
>>> print(np.round(ed_sp.Tip.amplitude[:5, 0, :], 4))
[[0.2894 0.2028]
[0.228 0.1531]
[0.0141 0.0377]
[0.0152 0.0455]
[0.0453 0.0325]]
estimate_error=True propagates first-order complex-Wishart impedance
uncertainty into the converted EDI’s >ZXX.VAR-style blocks using the
degrees of freedom inferred from the spectra file’s own averaging
metadata, rather than leaving every error at the config’s
error_fill_value.
3.6. Time-Series Conversion#
The least processed input this module accepts is a raw field recording:
TStoEDI reads Ex/Ey/Hx/Hy samples, builds
Hann-tapered overlapping segments, stacks their cross-power spectra with
Huber-robust weighting, and solves the same relation as
(1) for \(\mathbf{Z}\) – the only
difference is that the spectra are estimated here rather than read
pre-computed from an EDI block. data/MT/TS/kap103as.ts is a real LiMS
time-series recording from the EMSLAB Lincoln Line:
>>> from pycsamt.transformers import TStoEDI
>>> col_ts = TStoEDI(nfft=10240).transform(
... "data/MT/TS/kap103as.ts/kap103as.ts",
... )
>>> ed_ts = col_ts[0]
>>> print(ed_ts.station, ed_ts.Z.n_freq, ed_ts.has_tipper)
kap103 21 True
>>> print(ed_ts.Z.freq[:5])
[0.05 0.03619604 0.02620306 0.01896894 0.01373201]
>>> print(np.round(ed_ts.Z.res_xy[:5], 3))
[9.12 7.587 6.353 5.242 4.674]
Twenty-one output frequencies from one nfft=10240 FFT segment length
is the estimator’s own per-decade frequency choice, not the raw sample
count – per_decade controls that resolution directly if a denser or
coarser spectrum is wanted.
3.7. One Shape, Two Sources#
spectra01.edi and kap103as.ts could not start more differently –
one is pre-computed cross-power spectra sitting inside an EDI wrapper, the
other is a raw multi-day voltage recording – yet both converters hand
back the same EDI-shaped object with the same Z/Tip API. Plotting
both conversions together makes that convergence concrete:
1fig, axes = plt.subplots(1, 2, figsize=(11.0, 4.2))
2
3ax = axes[0]
4per_sp = 1.0 / ed_sp.Z.freq
5ax.loglog(per_sp, ed_sp.Z.res_xy, "o-", color="#1f77b4", ms=4, label=r"$\rho_{a,xy}$")
6tip_mag = np.hypot(ed_sp.Tip.amplitude[:, 0, 0], ed_sp.Tip.amplitude[:, 0, 1])
7ax3 = ax.twinx()
8ax3.loglog(per_sp, tip_mag, "^--", color="#2ca02c", ms=4, label="|tipper|")
9ax.set(xlabel="Period (s)", ylabel=r"$\rho_{a,xy}$ (field units)",
10 title=f"Spectra $\\to$ EDI ({ed_sp.station})")
11ax3.set_ylabel("|tipper|", color="#2ca02c")
12ax.grid(True, which="both", alpha=0.3)
13
14ax2 = axes[1]
15per_ts = 1.0 / ed_ts.Z.freq
16ax2.loglog(per_ts, ed_ts.Z.res_xy, "o-", color="#1f77b4", ms=4, label=r"$\rho_{a,xy}$")
17ax2.loglog(per_ts, ed_ts.Z.res_yx, "s-", color="#d62728", ms=4, label=r"$\rho_{a,yx}$")
18ax2.set(xlabel="Period (s)", ylabel=r"$\rho_a$ (field units)",
19 title=f"Time series $\\to$ EDI ({ed_ts.station})")
20ax2.legend(fontsize=8)
21ax2.grid(True, which="both", alpha=0.3)
22fig.tight_layout()
Left: SPECTRA01’s resistivity and tipper magnitude, both already
present in the source spectra block. Right: kap103’s two
off-diagonal resistivities, estimated here directly from raw voltage
samples. Different physics upstream, identical downstream shape.#
3.8. TEM Soundings To EDI#
pycsamt.tdem.transform.TEMtoEDI closes the same gap for
time-domain data: a transient decay has no natural frequency at all, so it
first has to be mapped onto a pseudo-frequency axis before it can sit in
an EDI’s Z block. The late-time apparent-resistivity formula behind
that mapping, the loop-geometry corrections, and the pseudo-frequency
convention are worked through in full in
TDEM Basics ((4)); this section only
covers the conversion contract itself. data/TEMAVG/JIANGSU is a real
2790-sounding TEM survey:
>>> from pycsamt.tdem import read_temavg_soundings, LateTimeTransform, TEMtoEDI
>>> soundings = read_temavg_soundings(
... "data/TEMAVG/JIANGSU", component="Hz", pattern="*.AVG",
... )
>>> print(len(soundings))
2790
>>> s0 = next(s for s in soundings if s.station_name == "TEM100_100")
>>> print(s0.n_gates, s0.offset, s0.loop_shape, s0.loop_dims, round(s0.moment, 1))
25 0.0 square (360.0,) 1296000.0
>>> lt = LateTimeTransform()
>>> result = lt.transform(s0)
>>> print(result["freq"][:5])
[13.04441792 16.37800929 20.54805282 25.87001887 32.50381764]
>>> print(np.round(result["rho_a"][:5], 2))
[1695.01 879.05 309.75 349.1 334.71]
>>> conv = TEMtoEDI(method="late_time")
>>> col_tem = conv.transform(s0)
>>> ed_tem = col_tem[0]
>>> print(type(col_tem).__name__, len(col_tem))
EDICollection 1
>>> print(ed_tem.station, ed_tem.Z.freq.shape)
TEM100_100 (25,)
>>> print(np.round(ed_tem.Z.res_xy[:5], 2))
[1695.01 879.05 309.75 349.1 334.71]
>>> print(np.round(result["rho_a"][:5] / ed_tem.Z.res_xy[:5], 1))
[1. 1. 1. 1. 1.]
Unlike the K1 line above, there is no scale gap here: ed_tem.Z.res_xy
comes back identical to result["rho_a"] (up to floating-point noise).
TEMtoEDI builds |Z_xy| as the exact inverse of the legacy Zonge
formula EDIFile.Z’s own res_xy property applies when it recomputes
resistivity from a packed z – \(|Z_{xy}|=\sqrt{5\,f\,\rho_a}\) –
specifically so that this round trip lands back on the same number. Read
either result["rho_a"] or ed_tem.Z.res_xy after conversion; they
agree, which also means an EDI written from this collection and reopened
later will report the same apparent resistivity a caller already trusted
from result.
The station’s own decay curve, alongside the apparent resistivity from
result, reproduces the figure below:
1fig, axes = plt.subplots(1, 2, figsize=(11.0, 4.2))
2
3ax = axes[0]
4ax.loglog(s0.time_gates * 1e3, np.abs(s0.dBdt()), "o-",
5 color="#1f77b4", ms=4, lw=1.3)
6ax.set(xlabel="Time (ms)", ylabel=r"$|\partial B_z/\partial t|$ (T/s)",
7 title=f"Decay curve ({s0.station_name})")
8ax.grid(True, which="both", alpha=0.3)
9
10ax2 = axes[1]
11ax2.loglog(result["freq"], result["rho_a"], "o-", color="#d62728", ms=4, lw=1.3)
12ax2.set(xlabel="Pseudo-frequency (Hz)", ylabel=r"$\rho_a$ ($\Omega\cdot$m)",
13 title="LateTimeTransform output")
14ax2.grid(True, which="both", alpha=0.3)
15fig.tight_layout()
Left: the raw decay, spanning almost five decades before flattening into the noise floor at the latest gates. Right: the transform’s own apparent resistivity, U-shaped rather than a clean sounding – the same honest, unsmoothed result already discussed in TDEM Basics.#
3.9. Batch Conversion And Failures#
AVGtoEDI and JtoEDI are all-or-nothing: transform raises on the
first problem it hits, which is appropriate for a single AVG line or
J-file where a broken station usually means the whole file is suspect.
SpectraToEDI and TStoEDI process many independent files at once, so
they default to skip_errors=True and expose
transform_batch(), which always
returns a TransformResult instead of
raising:
>>> from pathlib import Path
>>> batch_result = SpectraToEDI(skip_errors=True).transform_batch(
... ["data/MT/SPECTRA/spectra01.edi", "data/CSAMT/csa000.edi"],
... )
>>> print(batch_result)
TransformResult(ok=1, fail=1)
>>> for r in batch_result.failures:
... print(Path(r.source).name, "->", r.error)
...
csa000.edi -> No >=SPECTRASECT found.
>>> print(sorted(ed.station for ed in batch_result.collection))
['SPECTRA01']
csa000.edi is a perfectly good EDI – it simply carries a finished
impedance tensor rather than a raw spectra block, so
Spectra correctly refuses it. transform
(rather than transform_batch) would raise
RuntimeError with the same message the moment skip_errors=False,
which is the setting to use once a batch is expected to fully succeed.
3.10. Recording A Workflow Session#
Everything above converts one file at a time. pycsamt.session adds
two thin layers on top for a whole workflow: a single normalized entry
point across source formats, and light, on-disk provenance for whatever a
script actually produced.
Normalize dispatches any of the inputs this page
has covered – an AVG/JFile
object or path, a bare EDIFile, or an existing
EDICollection – to the matching
transformer and always hands back a collection:
>>> import shutil
>>> from pycsamt.session import Normalize
>>> shutil.rmtree("work/normalize_demo", ignore_errors=True) # fresh manifest below
>>> with Normalize("work/normalize_demo") as nz:
... coll = nz.load("data/avg/K1.AVG")
... same = nz.load(coll)
...
>>> print(type(coll).__name__, len(coll))
EDICollection 47
>>> print(same is coll)
True
An already-normalized EDICollection passes
straight through unchanged rather than being re-converted – Normalize
is meant to sit at the top of a script where the input format is not known
in advance, not to be called defensively before every operation. This is a
different normalization from ensure_sites’s own permissive input
handling covered in Loading electromagnetic data: Normalize dispatches raw
AVG/J/EDI sources to the matching transformer and returns an
EDICollection; ensure_sites accepts that (and more) and returns a
Sites.
Session instead wraps to_edi(),
AVGtoEDI.transform(), and JtoEDI.transform() for the duration
of a with block, so their outputs – not inputs – are recorded in
a small on-disk registry automatically, with no change to the conversion
code itself:
>>> from pycsamt.session import Session
>>> shutil.rmtree("work/session_demo", ignore_errors=True) # fresh manifest below
>>> with Session("work/session_demo", auto_capture=True) as ses:
... avg = AVG.from_file("data/avg/K1.AVG")
... coll2 = AVGtoEDI().transform(avg)
... hits = ses.reg.find(tag="AVGtoEDI.transform")
...
>>> len(hits), hits[0].kind, hits[0].tags
(1, 'edi_col', ['AVGtoEDI.transform'])
The wrapping is genuinely temporary: outside the with block,
AVGtoEDI.transform is the original method again, so an identical call
made after the session closes is not recorded:
>>> _ = AVGtoEDI().transform(avg) # outside the session now
>>> len(ses.reg.list())
1
Register something explicitly with ses.reg.add_object() when a script produces or
selects something the automatic capture would not see on its own, such as
one station pulled out of a collection:
>>> with Session("work/session_demo", auto_capture=True) as ses:
... one_edi = coll2[0]
... _ = ses.reg.add_object(one_edi, tags=["raw"])
... print(one_edi.station, len(ses.reg.list()))
...
S150 2
A new Session pointed at the same root reopens that directory’s
existing manifest.json rather than starting empty – the count above
is 2, not 1, because it includes the AVGtoEDI.transform record
from the previous block. Point every session at the same root across
an entire script, or across separate runs of the same pipeline, to build
up one running, on-disk manifest rather than a fresh one each time. The
interception itself is process-local and uses monkey-patching: safe for a
single script or notebook kernel, but avoid running two sessions
concurrently in different threads without adding your own locking.
work_session()
and normalize_session() are convenience wrappers
around Session(...) and Normalize(...) with the same defaults, and
all four names are also available directly off the package root –
pycsamt.Session, pycsamt.work_session, pycsamt.Normalize,
pycsamt.normalize_session – for a script that otherwise only imports
pycsamt itself.
3.11. Troubleshooting#
ValueError: no transfer functions in AVG/in J fileThe source parsed but produced no usable
zor(rho, phase). Check that the file actually reached its data block; a truncated header alone can leave every array empty.TypeError: source must be AVG or path/must be JFile or pathAVGtoEDI/JtoEDIwere given an object that is neither the expected container nor astr/Path. Load the file explicitly first (AVG.from_file(),JFile.from_file()) if the input is coming from somewhere unexpected.No >=SPECTRASECT foundThe EDI passed to
SpectraToEDIdoes not carry a raw cross-power spectra block – it is a standard impedance EDI already. Load it directly instead, or pointSpectraToEDIat the correct file.- Coordinates staying at
0.0 AVGtoEDI.post_emitonly copieslatitude/longitudecolumns by name. If a.stnfile stores projected easting/northing, callconvert_coords()with the correctutm_zone/epsgbefore conversion, as shown above.
3.12. See Also#
- pycsamt.transformers
Full
AVGtoEDI/JtoEDI/SpectraToEDI/TStoEDIAPI reference.- Impedance Tensor
Derivation of the SI-versus-Zonge apparent-resistivity convention gap referenced throughout this page.
- TDEM Basics
Full late-time apparent-resistivity derivation and loop-geometry corrections behind
LateTimeTransform.- Field Zones: Near, Transition, And Far Field
Background on scalar (single-off-diagonal-component) CSAMT lines like K1.
- Loading electromagnetic data
Normalizing already-converted EDI output into
Sitesfor downstream QC, correction, and inversion prep.pycsamt.core.configCoreConfig,StationNamePolicy, andconfig_contextused to control finalization behaviour globally or temporarily.pycsamt.sessionAPI reference for
Session,work_session,Normalize, andnormalize_session, covered above in “Recording A Workflow Session”.