Reusable studies

A study schedules repeated field solves with one built geometry. Choose a family by the objects and outputs required, not by the application’s name.

Table 8 Choose a study

Family / CSV type

Typical task

What changes

GPRStudy / gpr

B-scans, multistatic surveys, point-source acquisitions

Supported point-source/receiver positions and source waveforms/timing

SourceStudy / source

Multiple-feed antennas and weighted terminal drives

TL/frill/network generators; not the physical loads or positions

PortStudy / port

Complete finite-resistance voltage-port S matrix

Exactly one driven voltage port per case; other ports remain loaded

EigenmodeStudy / eigenmode

Multimode S matrices, embedded patterns and coherent array states

One declared port/mode channel per solve; optional post-solve codebook

PlaneWaveStudy / plane_wave

Incidence-angle/polarisation sweeps and RCS

One DPW template’s direction, polarisation and waveform

Geometry/material parameter sweeps require fresh Scenes: do not change a material, source resistance or network in a reused study. Moving a complete toolbox antenna requires moving/rebuilding its geometry, not a GPRStudy point-source override. See Optimisation toolbox. For many waveforms on the same linear system, Impulse-response waveform synthesis can replace repeated solves with convolution if the feed boundary condition remains unchanged. SFCW/FMCW processing is described in Stepped-Frequency Continuous-Wave GPR and Frequency-Modulated Continuous-Wave GPR.

Execution and reuse

Studies run sequential cases on the selected solver. CPU, CUDA, OpenCL and Metal may be used where the selected source, dimensionality and outputs are supported; a study does not extend a feature’s backend support. MPI domain decomposition and task farming are unsupported for every study family.

  • GPRStudy, SourceStudy and PortStudy manage main-grid objects. GPR studies can use supported reduced 2-D point-source models; terminal ports need 3-D.

  • EigenmodeStudy can manage ports on the main grid or an owning CPU or CUDA HSG subgrid. Ordinary dimensionality and virtual-guide restrictions still apply; see Eigenmode ports and S-parameter analysis.

  • PlaneWaveStudy uses a main-grid TFSF source and may enclose complete subgrids, but those subgrids cannot contain another excitation.

  • Use exactly one Scene or one hash input. The case count supplies n automatically. Do not add src_steps/rx_steps acquisition controls to a study.

  • Geometry and discretisation stay fixed. Fields, PML state, source histories and output accumulators reset per case. Modal bases are cached; direction-dependent auxiliary plane waves are rebuilt. Reuse does not mean every backend allocation or every source setup is free.

  • Source, Port, Eigenmode and PlaneWave studies require a solve and reject geometry_only. Inspect a modal scene with ordinary geometry_only=True before attaching its study.

Case IDs and restart

Give each case a unique ID. A CSV row describes one object state, not necessarily a whole case: all rows with the same case ID form one case, in order of first appearance of that ID. An object may be listed only once per case. Keep related rows together for readability. Object IDs identify declarations, not receiver group numbers. Prefer explicit IDs where supported and keep them stable across files.

Use Python i=N or command-line -i N to resume at one-based case N. Output numbering remains absolute. This is a restart at a case boundary, not a checkpoint within a time step. Port and eigenmode studies reuse compatible saved aggregate data; use a separate output prefix for a changed experiment.

Read results by task

Each solve produces <prefix>1.h5, <prefix>2.h5, etc., with requested receiver/port/NTFF outputs and /study provenance. Only PortStudy and EigenmodeStudy additionally produce <prefix>_study.h5 with an aggregate S matrix.

  • GPR: merge matching receiver identities and plot a B-scan; see the CSV acquisition example.

  • Source: compare per-case terminal histories and NTFF patterns. plot_port reads local terminal results, not a full S matrix.

  • Port: read S[frequency, output_port, input_port] with its validity mask and port-ID array, or use results['study'] after the solve.

  • Eigenmode: follow the channel port/mode arrays and distinguish coefficient validity from propagating power-wave validity.

  • PlaneWave: the observation requests stay fixed. Select the backscatter direction appropriate to each incidence for a monostatic curve; a fixed observation direction is not generally monostatic.

See Study outputs for schemas and Worked examples: model to plot for small runnable models. The scheduling snippets below assume an existing scene, its geometry and a pulse waveform.

GPR acquisitions

A gprMax.Study runs an ordered set of source and receiver states while reusing one built geometry. It is intended for arbitrary GPR acquisition patterns and underlies the specialised multiport, antenna-array, and plane-wave workflows. Every case restores the original object state before its overrides are applied, so parameters cannot accidentally accumulate between runs.

General GPR studies support top-level gprMax.HertzianDipole, gprMax.MagneticDipole, and gprMax.Rx objects on the main grid. A state can refer directly to its Python object or use its deterministic ID. Sources omitted from a case are inactive; receivers omitted from a case keep their original position and are recorded.

source = gprMax.HertzianDipole(
    polarisation='z', p1=(0.10, 0.05, 0.03), waveform_id='pulse'
)
receiver = gprMax.Rx(p1=(0.14, 0.05, 0.03), id='measurement')
scene.add(source)
scene.add(receiver)

study = gprMax.GPRStudy([
    gprMax.StudyCase('trace_1', [
        gprMax.ObjectState(source, position=(0.10, 0.05, 0.03), scale=1.0),
        gprMax.ObjectState(receiver, position=(0.14, 0.05, 0.03)),
    ]),
    gprMax.StudyCase('trace_2', [
        gprMax.ObjectState(source, position=(0.102, 0.052, 0.03), scale=0.8),
        gprMax.ObjectState('measurement', position=(0.145, 0.052, 0.03)),
    ]),
])

gprMax.run(scenes=[scene], study=study, outputfile='survey')

The available source overrides are active, position, waveform_id, start, stop, and the dimensionless amplitude scale. Receivers currently accept position and record=True. The study determines the run count automatically; pass i=N to restart at the one-based case number N. For a text input model the equivalent #study command reads the same information from CSV.

Study start/stop overrides must be finite. active and record accept Python or NumPy boolean scalars; strings, numeric flags and boolean arrays are rejected rather than interpreted by truthiness. These checks also apply before reusing a study in another run. record=False remains unsupported and is rejected for both boolean types.

For a complete acquisition that users can edit in a spreadsheet, see the CSV B-scan example. A Python model can use that same schedule with gprMax.Study.from_csv('gpr', 'path/to/cases.csv') and pass the returned object as the study argument to gprMax.run.

Fixed-topology terminal-source studies

A gprMax.SourceStudy reuses a model containing stateful terminal sources. It supports main-grid gprMax.TransmissionLine, gprMax.MagneticFrillSource, and gprMax.NetworkExcitation objects. Their positions and physical definitions remain fixed, but each case may change waveform_id, start, stop, and the dimensionless generator scale. active=false is equivalent to zero generator drive.

This is deliberately different from an S-parameter study: any number of terminals may be active in one case, which is useful for phased-array and multiple-feed antenna patterns. A source omitted from a case is not removed. Its transmission-line resistance, coaxial-frill termination, or rational network remains coupled to the Yee grid as a passive load.

scene.add(gprMax.RationalNetwork(id='load50', conductance=1 / 50))
scene.add(gprMax.NetworkTerminal(
    p1=(0.040, 0.050, 0.030), polarisation='z',
    network_id='load50', id='port1'
))
scene.add(gprMax.NetworkTerminal(
    p1=(0.060, 0.050, 0.030), polarisation='z',
    network_id='load50', id='port2'
))
scene.add(gprMax.NetworkPort('port1'))
scene.add(gprMax.NetworkPort('port2'))
feed1 = gprMax.NetworkExcitation('port1', 'pulse')
feed2 = gprMax.NetworkExcitation('port2', 'pulse')
scene.add(feed1)
scene.add(feed2)

study = gprMax.SourceStudy([
    gprMax.StudyCase('feed_1_only', [
        gprMax.ObjectState(feed1, scale=1),
    ]),
    gprMax.StudyCase('equal_feeds', [
        gprMax.ObjectState(feed1, scale=1),
        gprMax.ObjectState(feed2, scale=1),
    ]),
    gprMax.StudyCase('weighted_feeds', [
        gprMax.ObjectState(feed1, scale=1),
        gprMax.ObjectState(feed2, scale=-1),
    ]),
])

gprMax.run(scenes=[scene], study=study, outputfile='fed_array')

Before every case gprMax reconstructs the selected source waveform and clears all transmission-line voltage/current and ABC state, magnetic-frill recurrence and histories, rational-network pole state, receiver histories, and derived port results. Declarative NTFF monitors are recompiled with new accumulators, so every case may safely produce an independent antenna pattern. SourceStudy uses the normal CPU, CUDA, OpenCL, or Metal implementation of each terminal. It does not currently support source objects inside a subgrid, MPI execution, or task farming.

Finite-resistance voltage-port studies

A gprMax.PortStudy calculates a complete multiport S matrix without rebuilding the antenna geometry. Every finite-resistance gprMax.VoltageSource remains on its original electric edge in every case. Exactly one source is driven, while omitted sources have zero generator voltage but retain their resistance and therefore act as passive matched terminations. Every source automatically owns a port monitor with a unique explicit or automatically assigned ID; explicit IDs are recommended.

port1_source = gprMax.VoltageSource(
    p1=(0.040, 0.050, 0.030), polarisation='z', resistance=50,
    waveform_id='pulse', id='port1'
)
port2_source = gprMax.VoltageSource(
    p1=(0.060, 0.050, 0.030), polarisation='z', resistance=50,
    waveform_id='pulse', id='port2'
)
scene.add(port1_source)
scene.add(port2_source)

study = gprMax.PortStudy([
    gprMax.StudyCase('drive_port1', [
        gprMax.ObjectState(port1_source, scale=1.0),
    ]),
    gprMax.StudyCase('drive_port2', [
        gprMax.ObjectState(port2_source, scale=1.0),
    ]),
])

results = gprMax.run(scenes=[scene], study=study, outputfile='array')
smatrix = results['study'].s

The returned and stored matrix uses S[frequency, output_port, input_port]. Voltage waves are converted to power-wave normalisation, so ports may use different positive real reference impedances. The individual source gaps contain numerical background capacitance and conductance. These are removed from the complete admittance matrix:

\[\begin{split}\begin{aligned} \overline{\mathbf{Y}}_{\mathrm{s}} &= (\mathbf{I}-\mathbf{S}_{\mathrm{s}}) (\mathbf{I}+\mathbf{S}_{\mathrm{s}})^{-1}, \\ \overline{\mathbf{Y}} &= \overline{\mathbf{Y}}_{\mathrm{s}} - \operatorname{diag}(Z_{0,p}Y_{\mathrm{gap},p}), \\ \mathbf{S} &= (\mathbf{I}+\overline{\mathbf{Y}})^{-1} (\mathbf{I}-\overline{\mathbf{Y}}). \end{aligned}\end{split}\]

This matrix operation is important: applying the scalar one-port correction independently to off-diagonal elements is not mathematically valid. The per-case files contain the raw source-plane column, and array_study.h5 contains both S_source and the corrected S matrix. Restarting with i=N reuses compatible columns already present in this aggregate file.

Source position and resistance are immutable because both affect the built electric-edge material. The permitted case parameters are active, waveform_id, start, stop, and scale. Hard voltage sources are rejected because zero drive would impose a zero electric field rather than a matched passive termination.

Eigenmode-port studies and array synthesis

An gprMax.EigenmodeStudy constructs the complete modal S matrix by exciting one declared (port, mode) channel per case. The geometry, Yee arrays, FDFD modal solutions, phase-aligned anchor fields, and modal power normalisation are prepared once. Between cases gprMax clears the main and virtual-waveguide fields, PML histories, modal DFT accumulators, recursive DFT phases, and derived S data before selecting the next cached modal basis.

Every declared mode on every gprMax.EigenmodePort must appear in exactly one case. This deliberate one-active-channel policy gives ordinary S-parameters; exciting several ports in one solve would yield only the active relation \(b(f)=S(f)a(f)\), not all columns of \(S\).

The nominally passive ports can still contain a small measured incident wave because their finite terminations are not mathematically perfect. gprMax does not assume that the incident-wave matrix is diagonal. For the independent cases it assembles

\[A(f) = [a^{(1)}(f)\;\cdots\;a^{(N)}(f)], \qquad B(f) = [b^{(1)}(f)\;\cdots\;b^{(N)}(f)],\]

and obtains the authoritative aggregate matrix from

\[B(f)=S(f)A(f), \qquad S(f)=B(f)A(f)^{-1}.\]

The implementation uses a conditioned linear solve, not an explicit inverse. Bins with an incomplete, invalid, or ill-conditioned incident basis are marked invalid. The measured \(A\) and \(B\), their validity masks, condition number, and solve-valid flag are retained in the aggregate HDF5 file for audit and restart.

excitation = gprMax.EigenmodeExcitation(
    port=1, mode=1, waveform='auto', plot_waveform=False
)
scene.add(excitation)

study = gprMax.EigenmodeStudy([
    gprMax.StudyCase('p1m1', [
        gprMax.ObjectState(excitation, port=1, mode=1),
    ]),
    gprMax.StudyCase('p2m1', [
        gprMax.ObjectState(excitation, port=2, mode=1),
    ]),
])

results = gprMax.run(scenes=[scene], study=study, outputfile='array')
modal_s = results['study'].s

The corresponding hash command selects an eigenmode study and its case CSV:

#study: eigenmode cases.csv

For the two channels above, cases.csv contains:

case_id,object_id,port,mode
p1m1,eigenmode_excitation_1,1,1
p2m1,eigenmode_excitation_1,2,1

The matrix convention is S[frequency, output_channel, input_channel]. channel_ports and channel_modes define both channel axes. power_wave_valid_s and coefficient_valid_s are the physical power-wave and modal-coefficient masks on EigenmodeStudyResult. HDF5 uses power_wave_valid_S and coefficient_valid_S. An evanescent coefficient is not a propagating power wave. Compatible columns in an existing <output>_study.h5 are retained when restarting with i=N. More precisely, the compatible raw excitation cases are retained and the full de-embedding solve is repeated using all available cases.

Embedded far fields from the individual cases can be retained and combined without a new FDTD solve. Select an existing frequency-domain KSIR or equivalent-current far-field request, then define named states in an gprMax.ArrayCodebook:

codebook = gprMax.ArrayCodebook(
    states=[
        gprMax.ArrayState('broadside', [
            gprMax.ModalWeight(port=1, mode=1, power=1),
            gprMax.ModalWeight(port=2, mode=1, power=1),
        ]),
        gprMax.ArrayState('steered', [
            gprMax.ModalWeight(port=1, mode=1, power=1),
            gprMax.ModalWeight(port=2, mode=1, power=1, phase_deg=90),
        ]),
    ],
    embedded_far_fields=[
        gprMax.EmbeddedFarFieldSpec('antenna_band', 'pattern'),
    ],
)
study = gprMax.EigenmodeStudy(cases, codebook=codebook)
results = gprMax.run(scenes=[scene], study=study, outputfile='array')
steered = results['study'].evaluate_array_state(codebook.states[1])

The same versioned definition can be loaded from JSON with ArrayCodebook.from_json and serialized again with to_json. For hash-command models use #array_codebook: file.json alongside #study: eigenmode .... An existing aggregate can be reopened without another solve:

study_result = gprMax.EigenmodeStudyResult.from_hdf5('array_study.h5')
codebook = gprMax.ArrayCodebook.from_json('array_states.json')
states = study_result.evaluate_codebook(codebook)

Here power is incident modal power in watts, so the power-wave magnitude is its square root. With the engineering Fourier convention a constant phase uses \(\exp(+j\phi)\), whereas a delay uses \(\exp(-j2\pi f\tau)\). Constant phase produces ordinary narrowband beam steering and beam squint; true time delay preserves steering over bandwidth. In a lower-level embedded response array, the first axis is frequency and the selected channel axis follows channel_ports and channel_modes; use combine_embedded_modal_responses to combine such an array directly.

For state \(q\), gprMax forms the incident vector

\[a_{q,p}(f)=\sqrt{P_{q,p}}\, \exp\!\left(j\phi_{q,p}-j2\pi f\tau_{q,p}\right), \qquad b_q(f)=S(f)a_q(f).\]

It reports the active reflection coefficient \(b_{q,p}/a_{q,p}\) for each driven channel and

\[\mathrm{TARC}_q(f)= \sqrt{\frac{\sum_p |b_{q,p}(f)|^2} {\sum_p |a_{q,p}(f)|^2}}.\]

Complex embedded \(E_\theta\) and \(E_\phi\) fields use the same full incident-matrix de-embedding. If \(F_{\mathrm{runs}}\) contains the raw field from each case, gprMax solves \(F_{\mathrm{runs}}=F_{\mathrm{emb}}A\) for the embedded modal basis. A retained full-sphere quadrature is treated in the same way, so radiated power, directivity, gain, realized gain, and efficiencies include the coherent cross terms. Only physical propagating power-wave bins marked by power_wave_valid_S are used for these power metrics; generalized evanescent coefficients remain available in S but are not treated as watts. No embedded-field storage or full-sphere evaluation is performed unless a codebook explicitly selects a far-field output. With a selection, storage is proportional to the number of frequencies, quadrature directions, and modal channels. Both the raw case fields needed for restart/audit and the de-embedded modal basis are retained, approximately doubling the complex field storage compared with keeping only one representation. The synthesis assumes a linear, time-invariant antenna and feed model; a nonlinear or state-dependent feed network requires new driven simulations rather than post-processing.

The study methods above return structured result objects rather than scene commands. Their array shapes, channel ordering, units, and validity masks are documented in Advanced Model Building (Python API). These objects can also be reconstructed from a saved study file, allowing array states to be evaluated without rerunning FDTD.

Plane-wave and RCS studies

A gprMax.PlaneWaveStudy evaluates several incident plane waves while building the main Yee geometry only once. The Scene contains exactly one discrete-plane-wave object, which acts as the reusable template, and each case changes its direction, polarisation, timing, waveform, or amplitude. Other active source types are rejected so that scattered-field and RCS results have an unambiguous incident wave.

plane_wave = gprMax.DiscretePlaneWaveAngles(
    p1=(0.03, 0.03, 0.03),
    p2=(0.07, 0.07, 0.07),
    theta=90,
    phi=0,
    psi=90,
    waveform_id='pulse',
)
scene.add(plane_wave)

study = gprMax.PlaneWaveStudy([
    gprMax.StudyCase('x_incidence', [
        gprMax.ObjectState(plane_wave, theta=90, phi=0, psi=90),
    ]),
    gprMax.StudyCase('y_incidence', [
        gprMax.ObjectState(plane_wave, theta=90, phi=90, psi=90),
    ]),
])

gprMax.run(scenes=[scene], study=study, outputfile='angular_rcs')

The TFSF box, background material, and angular-approximation tolerance remain fixed because they define the reusable source topology. The parameters which may change depend on the template:

  • gprMax.DiscretePlaneWaveAngles: theta, phi, and psi;

  • gprMax.DiscretePlaneWaveVector: m_vec and psi;

  • gprMax.DiscretePlaneWaveAxial: axis and psi.

All three forms also accept per-case waveform_id, start, stop, and non-zero dimensionless scale. The principal Yee arrays and material IDs are retained, but the small auxiliary one-dimensional DPW grid is rebuilt for each case. This is necessary because its length, rational integer mapping, field projections, material profile, and PML state depend on the propagation direction.

Declarative NTFF transforms are also reconstructed for every case. Their surface geometry is reused, while all time/frequency accumulators and the incident-wave DFT are new. Consequently an RCS result cannot contain state from an earlier direction. Each numbered HDF5 file records the requested study case under /study and the actual rationalised plane-wave parameters under the frequency transform’s plane_wave group. A complete subgrid may be enclosed by the fixed TFSF and NTFF surfaces, subject to the normal enclosure rules, but it cannot contain another excitation. Far-field observation directions are part of the fixed output definition rather than a case parameter. Request every direction needed by the study (for example a complete angular sweep), then select the appropriate monostatic or bistatic direction from each case file.

Note

MPI/task-farm studies are not yet enabled. General GPR and SourceStudy objects remain main-grid only. Eigenmode studies support the owning main grid or subgrid and reset direct and virtual-waveguide modal state explicitly. Plane-wave studies use a main-grid TFSF source but may enclose complete subgrids.

Worked examples: model to plot

The small models below demonstrate scheduling, file schemas and plotting, not converged antenna designs or scattering benchmarks. The port models use two bare Yee-edge terminals in free space; no commercial antenna geometry or calibration data are involved. Use a longer record, finer mesh and larger domain/PML when assessing quantitative antenna performance.

Download the shared Python model builder and headless result plotter. Run the commands below from the repository root. The Python builder accepts --gpu 0 for CUDA device zero; the hash command equivalent is -gpu 0.

Passive receiving voltage port

passive.in runs once, without a study. Both gaps have 50 Ohm resistance and a free-space background; only the receiver’s generator waveform has zero amplitude.

#domain: 0.048 0.048 0.048
#dx_dy_dz: 0.002 0.002 0.002
#time_window: 1e-9
#pml_cells: 3
#omp_threads: 1
#waveform: ricker 1 5e9 pulse
#waveform: ricker 0 5e9 off
#voltage_source: z 0.020 0.024 0.024 50 pulse 0 1e-9 feed 10
#voltage_source: z 0.028 0.024 0.024 50 off 0 1e-9 receive 10
mkdir -p study_results
python -m gprMax examples/features/studies/passive.in -o study_results/passive_hash
python examples/features/studies/run_study.py passive --output study_results/passive_api
python examples/features/studies/plot_results.py passive study_results/passive_api
python -m gprMax.toolboxes.Plotting.plot_port study_results/passive_api.h5 --port feed --port receive --validity --save

The comparison PNG plots /ports/receive/Vtotal against its stored time axis. Its own source-normalised S11 is invalid, but the received voltage is useful. See Driven transmitter and passive receiver.

Fixed-network generator study

Download source.in and source.csv into the same directory. Two fixed rational 50 Ohm terminations are driven first from one end and then with opposite-polarity waveforms:

case_id,object_id,scale
feed_only,network_excitation_1,1
opposed,network_excitation_1,1
opposed,network_excitation_2,-1
python -m gprMax examples/features/studies/source.in -o study_results/source_hash
python examples/features/studies/run_study.py source --output study_results/source_api
python examples/features/studies/plot_results.py source study_results/source_api

Two numbered files contain received voltages and terminal results. The PNG compares the receiver voltage in both cases. This is not a complete S matrix. Transmission-line/frill SourceStudy cases use the same scheduling pattern, with the correct family-specific objects and passive terminations.

Complete voltage-port matrix

Download port.in and port.csv. Both terminals remain at fixed positions and resistance:

case_id,object_id,scale
drive_feed,voltage_source_1,1
drive_receive,voltage_source_2,1
python -m gprMax examples/features/studies/port.in -o study_results/port_hash
python examples/features/studies/run_study.py port --output study_results/port_api
python examples/features/studies/plot_results.py port study_results/port_api

The two case files are supplemented by port_api_study.h5. The plotter selects matrix elements using port_ids, masks them with valid_S, and plots all four magnitudes. The complete builder is:

def build_model(kind):
    """Return the reusable scene and study (None for the passive-Rx demo)."""
    if kind not in {"passive", "source", "port", "plane_wave"}:
        raise ValueError(f"Unknown tutorial: {kind}")
    scene = gprMax.Scene()
    scene.add(gprMax.Domain(p1=(0.048,) * 3))
    scene.add(gprMax.Discretisation(p1=(0.002,) * 3))
    scene.add(gprMax.TimeWindow(time=1e-9))
    scene.add(gprMax.PMLThickness(thickness=3))
    scene.add(gprMax.OMPThreads(n=1))
    scene.add(gprMax.Waveform(wave_type="ricker", amp=1, freq=5e9, id="pulse"))

    if kind == "plane_wave":
        wave = gprMax.DiscretePlaneWaveAngles(
            p1=(0.018,) * 3,
            p2=(0.030,) * 3,
            theta=90,
            phi=0,
            psi=90,
            waveform_id="pulse",
        )
        scene.add(wave)
        scene.add(gprMax.Sphere(p1=(0.024,) * 3, r=0.004, material_id="pec"))
        scene.add(
            gprMax.NTFFSurface(
                p1=(0.012,) * 3,
                p2=(0.036,) * 3,
                id="surface",
            )
        )
        scene.add(gprMax.KSIRFrequencyTransform("surface", "band", (5e9,)))
        for name, phi in (("back_x", 180), ("back_y", 270)):
            scene.add(
                gprMax.KSIRFarField(
                    theta=90,
                    phi=phi,
                    transform_id="band",
                    id=name,
                    outputs=("rcs",),
                )
            )
        study = gprMax.PlaneWaveStudy(
            [
                gprMax.StudyCase(name, [gprMax.ObjectState(wave, theta=90, phi=phi, psi=90)])
                for name, phi in (("from_x", 0), ("from_y", 90))
            ]
        )
        return scene, study

    points = ((0.020, 0.024, 0.024), (0.028, 0.024, 0.024))
    if kind == "source":
        scene.add(gprMax.RationalNetwork(id="load50", conductance=1 / 50))
        drives = []
        for name, point in zip(("feed", "receive"), points):
            scene.add(gprMax.NetworkTerminal(point, "z", "load50", name))
            scene.add(gprMax.NetworkPort(name))
            drive = gprMax.NetworkExcitation(name, "pulse")
            scene.add(drive)
            drives.append(drive)
        study = gprMax.SourceStudy(
            [
                gprMax.StudyCase("feed_only", [gprMax.ObjectState(drives[0], scale=1)]),
                gprMax.StudyCase(
                    "opposed",
                    [
                        gprMax.ObjectState(drives[0], scale=1),
                        gprMax.ObjectState(drives[1], scale=-1),
                    ],
                ),
            ]
        )
        return scene, study

    scene.add(gprMax.Waveform(wave_type="ricker", amp=0, freq=5e9, id="off"))
    feed = gprMax.VoltageSource(points[0], "z", 50, "pulse", id="feed")
    receive = gprMax.VoltageSource(
        points[1],
        "z",
        50,
        "off" if kind == "passive" else "pulse",
        id="receive",
    )
    scene.add(feed)
    scene.add(receive)
    if kind == "passive":
        return scene, None
    study = gprMax.PortStudy(
        [
            gprMax.StudyCase("drive_feed", [gprMax.ObjectState(feed)]),
            gprMax.StudyCase("drive_receive", [gprMax.ObjectState(receive)]),
        ]
    )
    return scene, study

Plane-wave incidence and RCS

Download plane_wave.in and plane_wave.csv. A small PEC sphere is illuminated from +x and +y, with fixed -x and -y observation requests:

case_id,object_id,theta_deg,phi_deg,psi_deg
from_x,plane_wave_1,90,0,90
from_y,plane_wave_1,90,90,90
python -m gprMax examples/features/studies/plane_wave.in -o study_results/plane_wave_hash
python examples/features/studies/run_study.py plane_wave --output study_results/plane_wave_api
python examples/features/studies/plot_results.py plane_wave study_results/plane_wave_api

The plot compares the two appropriate backscatter observations at 5 GHz: back_x from case 1 and back_y from case 2. Both requests are stored in both files; the unused request in each case is bistatic. Read ntff/surface/frequency/band/frequencies and far_field/<id>/fields/rcs within that transform, not a port FFT axis.

GPR acquisitions and multimode/array workflows

The CSV B-scan tutorial provides a full point-source acquisition, merge and plot workflow. Its Python equivalent can load the same schedule using Study.from_csv('gpr', csv_path).

For complete hash and Python modal models, plots and array-state examples, use examples 4 and 5 in Eigenmode ports and S-parameter analysis. Keep modal coefficient masks separate from power-wave masks and use the aggregate result for a complete matrix. The same chapter explains geometry-only mode inspection, virtual-guide constraints, cutoff and degenerate-mode tracking.