Skip to content

Implement electric and magnetic fields in GATE#900

Open
srmarcballestero wants to merge 39 commits intoOpenGATE:masterfrom
srmarcballestero:feature/fields
Open

Implement electric and magnetic fields in GATE#900
srmarcballestero wants to merge 39 commits intoOpenGATE:masterfrom
srmarcballestero:feature/fields

Conversation

@srmarcballestero
Copy link
Copy Markdown

Support for electric and magnetic fields in OpenGATE.

New features:

- UniformMagneticField: constant B field vector
- QuadrupoleMagneticField: gradient-based quadrupole field
- CustomMagneticField: user-defined Python callback returning [Bx, By, Bz]

- UniformElectricField: constant E field vector
- CustomElectricField: user-defined Python callback returning [Ex, Ey, Ez]

- UniformElectroMagneticField: combined constant B and E fields.
- CustomElectroMagneticField: user-defined callback returning [Bx, By, Bz, Ex, Ey, Ez]

- volume.add_field(field): to attach any field to a volume.

TODO's
- Add stepper type selection (currently hardcoded to G4ClassicalRK4)
- Add equation of motion type selection
- Bind G4SextupoleMagField
- Implement mapped fields (CSV field maps with trilinear interpolation)
- Add tests
- Add documentation

EXAMPLES
- the file test_fields.py contains an example of each of the implemented field classes (it is located in the root folder of the repo provisionally, to be removed and replaced with well-written tests).

Copilot AI review requested due to automatic review settings March 4, 2026 07:19
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds support for attaching electric, magnetic, and combined electromagnetic fields to geometry volumes in OpenGATE, with both built-in uniform/quadrupole implementations and Python-callback-based custom fields.

Changes:

  • Introduces new Python field classes (Uniform*, QuadrupoleMagneticField, Custom*) and a volume.add_field(field) API.
  • Extends the geometry construction engine to install Geant4 field managers on volumes during ConstructSDandField.
  • Adds Geant4 pybind11 bindings required for field managers, equations of motion, steppers, drivers, and chord finders, plus provisional example scripts.

Reviewed changes

Copilot reviewed 26 out of 26 changed files in this pull request and generated 17 comments.

Show a summary per file
File Description
opengate/geometry/fields.py New Python field class hierarchy and Geant4 field-manager construction logic.
opengate/geometry/volumes.py Adds field user-info and add_field() for attaching/registering fields.
opengate/managers.py Stores registered fields on VolumeManager.
opengate/engines.py Installs field managers on logical volumes during geometry field construction.
core/opengate_core/opengate_core.cpp Registers new Geant4 binding initializers.
core/opengate_core/g4_bindings/pyG4*.cpp Adds pybind11 bindings for Geant4 field-related classes and Python trampolines.
test_fields.py / fields_test_analytical_B.py Provisional example scripts demonstrating field usage and validation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +39 to +53
py::list field_list = result.cast<py::list>();

if (py::len(field_list) != 3) {
throw std::invalid_argument(
"GetFieldValue for G4ElectricField must return exactly 3 "
"components [Ex, Ey, Ez]");
}

// User returned [Ex, Ey, Ez]
field[0] = 0.;
field[1] = 0.;
field[2] = 0.;
field[3] = field_list[0].cast<G4double>();
field[4] = field_list[1].cast<G4double>();
field[5] = field_list[2].cast<G4double>();
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The binding casts the Python override result with result.cast<py::list>(), which rejects tuples/NumPy arrays. Accept any Python sequence (e.g., py::sequence) or convert to a list before indexing so user callbacks can return common sequence types.

Copilot uses AI. Check for mistakes.
Comment on lines +34 to +56
py::function override = py::get_override(this, "GetFieldValue");
if (override) {
py::object result = override(pyPoint);

if (!result.is_none()) {
py::list field_list = result.cast<py::list>();

if (py::len(field_list) != 3) {
throw std::invalid_argument(
"GetFieldValue for G4ElectricField must return exactly 3 "
"components [Ex, Ey, Ez]");
}

// User returned [Ex, Ey, Ez]
field[0] = 0.;
field[1] = 0.;
field[2] = 0.;
field[3] = field_list[0].cast<G4double>();
field[4] = field_list[1].cast<G4double>();
field[5] = field_list[2].cast<G4double>();
}
}
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the Python override is missing or returns None, the output field array is left untouched. For safety, initialize the expected components (Bx,By,Bz,Ex,Ey,Ez) to 0.0 before attempting to apply the Python override, or raise an error when no override is present.

Copilot uses AI. Check for mistakes.
Comment on lines +546 to +555
def add_field(self, field: FieldBase):
if self.field is not None:
fatal(
f"Volume '{self.name}' already has a field attached ('{self.field}'). "
f"A volume can only have one field. Remove the existing field first."
)
self.field = field.name
field.attached_to.append(self.name)
self.volume_manager.fields.update({field.name: field})

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New field types and volume attachment logic are introduced, but there are no automated tests under opengate/tests/ covering field attachment, stepping, and custom Python callbacks. Add at least one geometry test that runs a minimal simulation with a uniform field and asserts expected deflection / that the field manager is installed on the target logical volume (and that nested-volume overrides behave as intended).

Copilot uses AI. Check for mistakes.
Comment on lines +551 to +555
)
self.field = field.name
field.attached_to.append(self.name)
self.volume_manager.fields.update({field.name: field})

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add_field() allows the same Field instance to be attached to multiple volumes (field.attached_to.append(...)), but the current Field.create_field_manager() implementation stores Geant4 objects on self and will be unsafe if called more than once (dangling pointers / overwritten internal state). Either enforce one-volume-per-field-instance here (fail if field.attached_to is non-empty) or change the field implementation to support multi-attachment safely.

Copilot uses AI. Check for mistakes.
Comment on lines +107 to +123
"""Construct the field and return a configured G4FieldManager."""
# Create the G4 field object (subclass responsibility)
self._create_field()

# Create equation of motion, stepper, driver, chord finder TODO: allow user choice
self.g4_equation_of_motion = g4.G4Mag_UsualEqRhs(self.g4_field)
self.g4_integrator_stepper = g4.G4ClassicalRK4(
self.g4_equation_of_motion,
6, # number of variables for magnetic field = 6 (x,y,z + px,py,pz)
)
self.g4_integration_driver = g4.G4MagInt_Driver(
self.step_minimum,
self.g4_integrator_stepper,
6, # number of variables for magnetic field = 6 (x,y,z + px,py,pz)
0, # no verbosity
)
self.g4_chord_finder = g4.G4ChordFinder(self.g4_integration_driver)
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_field_manager() always calls _create_field() and recreates the equation/stepper/driver/chord-finder every time it’s invoked, overwriting self.g4_* attributes. If it can be called more than once (e.g., same field attached to multiple volumes, or multiple runs), this can invalidate previously returned G4FieldManager instances. Cache and reuse the created Geant4 objects (or make the method idempotent), or avoid storing these objects on self if you intend to create independent managers.

Suggested change
"""Construct the field and return a configured G4FieldManager."""
# Create the G4 field object (subclass responsibility)
self._create_field()
# Create equation of motion, stepper, driver, chord finder TODO: allow user choice
self.g4_equation_of_motion = g4.G4Mag_UsualEqRhs(self.g4_field)
self.g4_integrator_stepper = g4.G4ClassicalRK4(
self.g4_equation_of_motion,
6, # number of variables for magnetic field = 6 (x,y,z + px,py,pz)
)
self.g4_integration_driver = g4.G4MagInt_Driver(
self.step_minimum,
self.g4_integrator_stepper,
6, # number of variables for magnetic field = 6 (x,y,z + px,py,pz)
0, # no verbosity
)
self.g4_chord_finder = g4.G4ChordFinder(self.g4_integration_driver)
"""Construct the field and return a configured G4FieldManager.
This method is idempotent with respect to the underlying Geant4 field,
equation of motion, stepper, integration driver and chord finder:
those objects are created once per MagneticField instance and then
reused on subsequent calls. A new G4FieldManager is created and
configured for each call.
"""
# Lazily create the G4 field object (subclass responsibility)
if self.g4_field is None:
self._create_field()
# Lazily create equation of motion, stepper, driver, chord finder
# TODO: allow user choice of equation and stepper types
if self.g4_equation_of_motion is None:
self.g4_equation_of_motion = g4.G4Mag_UsualEqRhs(self.g4_field)
if self.g4_integrator_stepper is None:
self.g4_integrator_stepper = g4.G4ClassicalRK4(
self.g4_equation_of_motion,
6, # number of variables for magnetic field = 6 (x,y,z + px,py,pz)
)
if self.g4_integration_driver is None:
self.g4_integration_driver = g4.G4MagInt_Driver(
self.step_minimum,
self.g4_integrator_stepper,
6, # number of variables for magnetic field = 6 (x,y,z + px,py,pz)
0, # no verbosity
)
if self.g4_chord_finder is None:
self.g4_chord_finder = g4.G4ChordFinder(self.g4_integration_driver)
# Always ensure the chord finder is configured with the current tolerance

Copilot uses AI. Check for mistakes.
Comment on lines +229 to 231
# Field attached to this volume (only one allowed)
self.field = None

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.field = None in __init__ overwrites the user-info property field unconditionally, which will discard any field=... passed via kwargs or inherited from a template. Since user_info_defaults already provides the default, this line should be removed (or replaced by an internal attribute with a different name if needed).

Suggested change
# Field attached to this volume (only one allowed)
self.field = None

Copilot uses AI. Check for mistakes.
Comment on lines +227 to 231
self.g4_field_manager = None

# Field attached to this volume (only one allowed)
self.field = None

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

g4_field_manager is introduced on the volume, but it isn’t cleared in release_g4_references() / __getstate__(). That can leave stale Geant4 pointers across close()/pickling and makes the existing debug warning in GateObject.__getstate__ more likely to trigger. Add self.g4_field_manager = None to the release/pickle reset logic alongside the other G4 references.

Copilot uses AI. Check for mistakes.
Comment on lines +552 to +554
self.field = field.name
field.attached_to.append(self.name)
self.volume_manager.fields.update({field.name: field})
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add_field() registers the field via self.volume_manager.fields.update({field.name: field}) without checking for name collisions. If two distinct fields share a name, the latter silently overwrites the former and may attach the wrong Geant4 field to volumes. Reject duplicate names (or auto-rename) before updating the manager.

Suggested change
self.field = field.name
field.attached_to.append(self.name)
self.volume_manager.fields.update({field.name: field})
existing = self.volume_manager.fields.get(field.name)
if existing is not None and existing is not field:
fatal(
f"A field named '{field.name}' is already registered in the volume manager. "
f"Field names must be unique; choose a different name for this field."
)
self.field = field.name
field.attached_to.append(self.name)
self.volume_manager.fields[field.name] = field

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +53
if (py::len(result) != 3) {
throw std::invalid_argument(
"GetFieldValue for G4MagneticField must return exactly 3 "
"components [Bx, By, Bz]");
}

py::list bfield_list = result.cast<py::list>();
Bfield[0] = bfield_list[0].cast<G4double>();
Bfield[1] = bfield_list[1].cast<G4double>();
Bfield[2] = bfield_list[2].cast<G4double>();
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The binding casts the Python override result with result.cast<py::list>(), which rejects tuples/NumPy arrays even though they are common return types for vector-like data. Accept any Python sequence (e.g., cast to py::sequence or convert via py::list(result)) before indexing.

Suggested change
if (py::len(result) != 3) {
throw std::invalid_argument(
"GetFieldValue for G4MagneticField must return exactly 3 "
"components [Bx, By, Bz]");
}
py::list bfield_list = result.cast<py::list>();
Bfield[0] = bfield_list[0].cast<G4double>();
Bfield[1] = bfield_list[1].cast<G4double>();
Bfield[2] = bfield_list[2].cast<G4double>();
py::sequence bfield_seq = result.cast<py::sequence>();
if (py::len(bfield_seq) != 3) {
throw std::invalid_argument(
"GetFieldValue for G4MagneticField must return exactly 3 "
"components [Bx, By, Bz]");
}
Bfield[0] = bfield_seq[0].cast<G4double>();
Bfield[1] = bfield_seq[1].cast<G4double>();
Bfield[2] = bfield_seq[2].cast<G4double>();

Copilot uses AI. Check for mistakes.
Comment on lines +39 to +57
py::function override = py::get_override(this, "GetFieldValue");
if (override) {
py::object result = override(pyPoint);
if (!result.is_none()) {

if (py::len(result) != 3) {
throw std::invalid_argument(
"GetFieldValue for G4MagneticField must return exactly 3 "
"components [Bx, By, Bz]");
}

py::list bfield_list = result.cast<py::list>();
Bfield[0] = bfield_list[0].cast<G4double>();
Bfield[1] = bfield_list[1].cast<G4double>();
Bfield[2] = bfield_list[2].cast<G4double>();
}
}
// If no override, Bfield remains unmodified (caller should initialize)
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If no Python override is provided, the code leaves Bfield unmodified. Geant4 callers generally expect GetFieldValue implementations to always fill the output array, so this can propagate uninitialized values. Initialize Bfield to zero (or raise a clear exception) when no override is found / when the override returns None.

Copilot uses AI. Check for mistakes.
@tbaudier
Copy link
Copy Markdown
Contributor

Hello @srmarcballestero

Is it possible to rebase with the current master please? If you do not know how to do, I can help you
Thank you

Copy link
Copy Markdown
Author

@srmarcballestero srmarcballestero left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @srmarcballestero

Is it possible to rebase with the current master please? If you do not know how to do, I can help you
Thank you

Hi @tbaudier,

I have synced the fork and updated the branch feature/fields to accomodate the changes in the base branch. Please let me know if that worked. Thanks!

Best,

Marc

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.

4 participants