Implement electric and magnetic fields in GATE#900
Implement electric and magnetic fields in GATE#900srmarcballestero wants to merge 39 commits intoOpenGATE:masterfrom
Conversation
…ine and MagneticField classes
There was a problem hiding this comment.
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 avolume.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.
| 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>(); |
There was a problem hiding this comment.
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.
| 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>(); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| 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}) | ||
|
|
There was a problem hiding this comment.
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).
| ) | ||
| self.field = field.name | ||
| field.attached_to.append(self.name) | ||
| self.volume_manager.fields.update({field.name: field}) | ||
|
|
There was a problem hiding this comment.
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.
| """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) |
There was a problem hiding this comment.
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.
| """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 |
| # Field attached to this volume (only one allowed) | ||
| self.field = None | ||
|
|
There was a problem hiding this comment.
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).
| # Field attached to this volume (only one allowed) | |
| self.field = None |
| self.g4_field_manager = None | ||
|
|
||
| # Field attached to this volume (only one allowed) | ||
| self.field = None | ||
|
|
There was a problem hiding this comment.
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.
| self.field = field.name | ||
| field.attached_to.append(self.name) | ||
| self.volume_manager.fields.update({field.name: field}) |
There was a problem hiding this comment.
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.
| 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 |
| 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>(); |
There was a problem hiding this comment.
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.
| 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>(); |
| 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) | ||
| } |
There was a problem hiding this comment.
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.
…ethods, and keep references to geant4 runtime objects
|
Hello @srmarcballestero Is it possible to rebase with the current master please? If you do not know how to do, I can help you |
srmarcballestero
left a comment
There was a problem hiding this comment.
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
Support for electric and magnetic fields in OpenGATE.
New features:
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).