From a7c5d730c6dd12d07900577c7f40eee93f17a1a0 Mon Sep 17 00:00:00 2001 From: Nils Krah Date: Wed, 5 Nov 2025 12:55:41 +0100 Subject: [PATCH 01/15] Fix: Allow UserLimits to be applied to any particle, not just proton, electron, positron, gamma. --- opengate/managers.py | 47 +++++++++++++++++++++----------------------- opengate/physics.py | 43 ++++++++++++++++++---------------------- 2 files changed, 41 insertions(+), 49 deletions(-) diff --git a/opengate/managers.py b/opengate/managers.py index a2bc93fb7..2f2d90228 100644 --- a/opengate/managers.py +++ b/opengate/managers.py @@ -629,18 +629,9 @@ class PhysicsManager(GateObject): }, ), "user_limits_particles": ( - Box( - [ - ("all", False), - ("all_charged", True), - ("gamma", False), - ("electron", False), - ("positron", False), - ("proton", False), - ] - ), + ["all_charged"], { - "doc": "Switch on (True) or off (False) UserLimits, e.g. step limiter, for individual particles. Default: Step limiter is applied to all charged particles (in accordance with G4 default)." + "doc": "List of particles to which UserLimits, e.g. step limiter, are applied. Default: Step limiter is applied to all charged particles (in accordance with G4 default)." }, ), "em_parameters": ( @@ -879,11 +870,15 @@ def _normalize_volume_name(self, volume): fatal(f"Expected a volume name or a volume object, but received: {volume}") def find_or_create_region(self, volume_name): - if volume_name not in self.volumes_regions_lut: - region = self.add_region(volume_name + "_region") + if volume_name == self.simulation.world.name: + region_name = "DefaultRegionForTheWorld" + else: + region_name = volume_name + "_region" + if region_name not in self.volumes_regions_lut: + region = self.add_region(region_name) region.associate_volume(volume_name) else: - region = self.volumes_regions_lut[volume_name] + region = self.volumes_regions_lut[region_name] return region def get_biasing_particles_and_processes(self): @@ -983,17 +978,19 @@ def set_track_structure_em_physics_in_region( def set_user_limits_particles(self, particle_names): if not isinstance(particle_names, (list, set, tuple)): particle_names = list([particle_names]) - for pn in list(particle_names): - # try to get current value to check if particle_name is eligible - try: - _ = self.user_info.user_limits_particles[pn] - except KeyError: - fatal( - f"Found unknown particle name '{pn}' in set_user_limits_particles(). Eligible names are " - + ", ".join(list(self.user_info.user_limits_particles.keys())) - + "." - ) - self.user_info.user_limits_particles[pn] = True + self.user_info.user_limits_particles = particle_names + + # for pn in list(particle_names): + # # try to get current value to check if particle_name is eligible + # try: + # _ = self.user_info.user_limits_particles[pn] + # except KeyError: + # fatal( + # f"Found unknown particle name '{pn}' in set_user_limits_particles(). Eligible names are " + # + ", ".join(list(self.user_info.user_limits_particles.keys())) + # + "." + # ) + # self.user_info.user_limits_particles[pn] = True def freeze_config(self): # Freeze the Python-side configuration before any SimulationEngine is diff --git a/opengate/physics.py b/opengate/physics.py index 395d89139..df0a5fb34 100644 --- a/opengate/physics.py +++ b/opengate/physics.py @@ -74,42 +74,37 @@ def ConstructProcess(self): """ ui = self.physics_engine.user_info_physics_manager - particle_keys_to_consider = [] - # 'all' overrides individual settings - if ui.user_limits_particles["all"] is True: - particle_keys_to_consider = list(ui.user_limits_particles.keys()) - else: - keys_to_exclude = ("all", "all_charged") - particle_keys_to_consider = [ - p - for p, v in ui.user_limits_particles.items() - if v is True and p not in keys_to_exclude - ] - - if len(particle_keys_to_consider) == 0: + if len(ui.user_limits_particles) == 0: self.physics_engine.simulation_engine.simulation.warn_user( - "user_limits_particles is False for all particles. No tracking cuts will be applied. Use sim.physics_manager.set_user_limits_particles()." + "No particles selected to which UserLimits (incl. step limiter) will be applied. No tracking cuts will be applied. Use sim.physics_manager.set_user_limits_particles()." ) - # translate to Geant4 particle names + # process user input parameters + flag_all = "all" in ui.user_limits_particles + flag_all_charged = "all_charged" in ui.user_limits_particles particles_to_consider = [ - translate_particle_name_gate_to_geant4(k) for k in particle_keys_to_consider - ] + translate_particle_name_gate_to_geant4(p) for p in ui.user_limits_particles + if p not in ["all", "all_charged"] + ] - for particle in g4.G4ParticleTable.GetParticleTable().GetParticleList(): + known_g4_particles = list(g4.G4ParticleTable.GetParticleTable().GetParticleList()) + known_g4_particle_names = [p.GetParticleName() for p in known_g4_particles] + + # check for consistency of particle names + wrong_particle_names = [p for p in particles_to_consider if p not in known_g4_particle_names] + if len(wrong_particle_names) > 0: + fatal(f"UserLimits were requested for the following unknown particles: {wrong_particle_names}") + + for particle in known_g4_particles: add_step_limiter = False add_user_special_cuts = False p_name = str(particle.GetParticleName()) - if p_name in particles_to_consider: + if flag_all or p_name in particles_to_consider: add_step_limiter = True add_user_special_cuts = True - # this reproduces the logic of the Geant4's G4StepLimiterPhysics class - if ( - ui.user_limits_particles["all_charged"] is True - and particle.GetPDGCharge() != 0 - ): + elif flag_all_charged and particle.GetPDGCharge() != 0: add_step_limiter = True if add_step_limiter is True or add_user_special_cuts is True: From d2e92ecba746cc2477f7258bb191b85e480b66d9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 12:04:14 +0000 Subject: [PATCH 02/15] [pre-commit.ci] Automatic python and c++ formatting --- opengate/physics.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/opengate/physics.py b/opengate/physics.py index df0a5fb34..2ca6b3ec6 100644 --- a/opengate/physics.py +++ b/opengate/physics.py @@ -83,17 +83,24 @@ def ConstructProcess(self): flag_all = "all" in ui.user_limits_particles flag_all_charged = "all_charged" in ui.user_limits_particles particles_to_consider = [ - translate_particle_name_gate_to_geant4(p) for p in ui.user_limits_particles - if p not in ["all", "all_charged"] - ] + translate_particle_name_gate_to_geant4(p) + for p in ui.user_limits_particles + if p not in ["all", "all_charged"] + ] - known_g4_particles = list(g4.G4ParticleTable.GetParticleTable().GetParticleList()) + known_g4_particles = list( + g4.G4ParticleTable.GetParticleTable().GetParticleList() + ) known_g4_particle_names = [p.GetParticleName() for p in known_g4_particles] # check for consistency of particle names - wrong_particle_names = [p for p in particles_to_consider if p not in known_g4_particle_names] + wrong_particle_names = [ + p for p in particles_to_consider if p not in known_g4_particle_names + ] if len(wrong_particle_names) > 0: - fatal(f"UserLimits were requested for the following unknown particles: {wrong_particle_names}") + fatal( + f"UserLimits were requested for the following unknown particles: {wrong_particle_names}" + ) for particle in known_g4_particles: add_step_limiter = False From 5dd64d4944f425070839c2aed5c96e1c01366979 Mon Sep 17 00:00:00 2001 From: Nils Krah Date: Fri, 7 Nov 2025 12:10:07 +0100 Subject: [PATCH 03/15] Adapt test065_dose_from_edep_ct.py to new PhysicsManager.user_limits_particles API --- opengate/tests/src/actors/test065_dose_from_edep_ct.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opengate/tests/src/actors/test065_dose_from_edep_ct.py b/opengate/tests/src/actors/test065_dose_from_edep_ct.py index 36c6ea938..79633daf0 100755 --- a/opengate/tests/src/actors/test065_dose_from_edep_ct.py +++ b/opengate/tests/src/actors/test065_dose_from_edep_ct.py @@ -117,7 +117,7 @@ # physics sim.physics_manager.physics_list_name = "FTFP_INCLXX_EMZ" sim.physics_manager.set_production_cut("world", "all", 1000 * km) - sim.physics_manager.user_limits_particles.all = True + sim.physics_manager.user_limits_particles = "all" # add dose actor dose_postprocess = sim.add_actor("DoseActor", "dose_postprocess") From 2717513c28b2f11a536cc71622e44168ee52f78f Mon Sep 17 00:00:00 2001 From: Nils Krah Date: Fri, 7 Nov 2025 12:21:20 +0100 Subject: [PATCH 04/15] Introduce _setter_hook_user_limits_particles --- opengate/managers.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/opengate/managers.py b/opengate/managers.py index 2f2d90228..96aede4f1 100644 --- a/opengate/managers.py +++ b/opengate/managers.py @@ -573,6 +573,13 @@ def _setter_hook_physics_list_name(self, physics_list_name): return physics_list_name +def _setter_hook_user_limits_particles(self, particle_names): + if not isinstance(particle_names, (list, set, tuple)): + return list([particle_names]) + else: + return particle_names + + class PhysicsManager(GateObject): """ Everything related to the physics (lists, cuts, etc.) should be here. @@ -631,7 +638,9 @@ class PhysicsManager(GateObject): "user_limits_particles": ( ["all_charged"], { - "doc": "List of particles to which UserLimits, e.g. step limiter, are applied. Default: Step limiter is applied to all charged particles (in accordance with G4 default)." + "doc": "List of particles to which UserLimits, e.g. step limiter, are applied. Default: Step limiter " + "is applied to all charged particles (in accordance with G4 default).", + "setter_hook": _setter_hook_user_limits_particles, }, ), "em_parameters": ( From e9786031da074907199adaead6e8d2207e4a68b7 Mon Sep 17 00:00:00 2001 From: Nils Krah Date: Fri, 7 Nov 2025 12:29:04 +0100 Subject: [PATCH 05/15] Deprecate PhysicsManager.set_user_limits_particles because it has become trivial and unnecessary --- opengate/managers.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/opengate/managers.py b/opengate/managers.py index 96aede4f1..38d8383f9 100644 --- a/opengate/managers.py +++ b/opengate/managers.py @@ -985,9 +985,15 @@ def set_track_structure_em_physics_in_region( region.track_structure_em_physics = track_structure_em_physics def set_user_limits_particles(self, particle_names): - if not isinstance(particle_names, (list, set, tuple)): - particle_names = list([particle_names]) - self.user_info.user_limits_particles = particle_names + raise GateDeprecationError("The function set_user_limits_particles has been removed. Set the particle(s) directly via: \n" + "sim.physics_manager.user_limits_particles = XXX, \n" + "e.g.\n" + "sim.physics_manager.user_limits_particles = 'proton'\n" + "or \n" + "sim.physics_manager.user_limits_particles = ['gamma', 'electron']") + # if not isinstance(particle_names, (list, set, tuple)): + # particle_names = list([particle_names]) + # self.user_info.user_limits_particles = particle_names # for pn in list(particle_names): # # try to get current value to check if particle_name is eligible From 1573cad42c94de2723903511a910ef6c9b47f932 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 7 Nov 2025 11:29:50 +0000 Subject: [PATCH 06/15] [pre-commit.ci] Automatic python and c++ formatting --- opengate/managers.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/opengate/managers.py b/opengate/managers.py index 38d8383f9..ebd2d4256 100644 --- a/opengate/managers.py +++ b/opengate/managers.py @@ -639,7 +639,7 @@ class PhysicsManager(GateObject): ["all_charged"], { "doc": "List of particles to which UserLimits, e.g. step limiter, are applied. Default: Step limiter " - "is applied to all charged particles (in accordance with G4 default).", + "is applied to all charged particles (in accordance with G4 default).", "setter_hook": _setter_hook_user_limits_particles, }, ), @@ -985,12 +985,14 @@ def set_track_structure_em_physics_in_region( region.track_structure_em_physics = track_structure_em_physics def set_user_limits_particles(self, particle_names): - raise GateDeprecationError("The function set_user_limits_particles has been removed. Set the particle(s) directly via: \n" - "sim.physics_manager.user_limits_particles = XXX, \n" - "e.g.\n" - "sim.physics_manager.user_limits_particles = 'proton'\n" - "or \n" - "sim.physics_manager.user_limits_particles = ['gamma', 'electron']") + raise GateDeprecationError( + "The function set_user_limits_particles has been removed. Set the particle(s) directly via: \n" + "sim.physics_manager.user_limits_particles = XXX, \n" + "e.g.\n" + "sim.physics_manager.user_limits_particles = 'proton'\n" + "or \n" + "sim.physics_manager.user_limits_particles = ['gamma', 'electron']" + ) # if not isinstance(particle_names, (list, set, tuple)): # particle_names = list([particle_names]) # self.user_info.user_limits_particles = particle_names From 0d3c21fbccb2b1276012b286b983d7b304249e87 Mon Sep 17 00:00:00 2001 From: Nils Krah Date: Mon, 10 Nov 2025 14:47:35 +0100 Subject: [PATCH 07/15] Fix find_or_create_region() --- opengate/managers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/opengate/managers.py b/opengate/managers.py index ebd2d4256..f8443502e 100644 --- a/opengate/managers.py +++ b/opengate/managers.py @@ -883,11 +883,11 @@ def find_or_create_region(self, volume_name): region_name = "DefaultRegionForTheWorld" else: region_name = volume_name + "_region" - if region_name not in self.volumes_regions_lut: + if volume_name not in self.volumes_regions_lut: region = self.add_region(region_name) region.associate_volume(volume_name) else: - region = self.volumes_regions_lut[region_name] + region = self.volumes_regions_lut[volume_name] return region def get_biasing_particles_and_processes(self): From 3d6eccd67a5fc7344d6ed077951c280cd6a49eec Mon Sep 17 00:00:00 2001 From: Nils Krah Date: Thu, 21 May 2026 21:01:33 +0200 Subject: [PATCH 08/15] Update UserLimits particles documentation --- docs/source/user_guide/user_guide_physics.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/user_guide/user_guide_physics.rst b/docs/source/user_guide/user_guide_physics.rst index c5fc5b57c..4381b663d 100644 --- a/docs/source/user_guide/user_guide_physics.rst +++ b/docs/source/user_guide/user_guide_physics.rst @@ -131,7 +131,7 @@ Additionally, you need to tell GATE to which particles you want to apply the ste .. code-block:: python - sim.physics_manager.set_user_limits_particles(['electron', 'positron']) + sim.physics_manager.user_limits_particles = ['electron', 'positron'] There are other user limits like ''maximum track length'' and ''minimium kinetic energy'', that are used in analogy to the ''maximum step size''. You can also use Regions if your geometry is complex. Have a look at the section :ref:`user-limits-details-label` in the detailed part of this user guide for more info. From 00c29b00100a760702e9ef9706af239c631900c4 Mon Sep 17 00:00:00 2001 From: Nils Krah Date: Thu, 21 May 2026 21:01:47 +0200 Subject: [PATCH 09/15] Update step limiter test particle selection --- opengate/tests/src/actors/test065_dose_from_edep_ct.py | 7 ++++++- opengate/tests/src/actors/test081_pgtle_stage1.py | 2 +- opengate/tests/src/actors/test081_tle_1_geom.py | 2 +- opengate/tests/src/actors/test081_tle_4_high_energy.py | 2 +- opengate/tests/src/actors/test081_tle_5_db.py | 2 +- opengate/tests/src/actors/test081_tle_6_brem_splitting.py | 2 +- opengate/tests/src/physics/test053_step_limiter_mt.py | 2 +- .../tests/src/source/test059_tpsource_LUT_beammodel.py | 2 +- opengate/tests/src/source/test059_tpsource_optics.py | 7 ++++++- 9 files changed, 19 insertions(+), 9 deletions(-) diff --git a/opengate/tests/src/actors/test065_dose_from_edep_ct.py b/opengate/tests/src/actors/test065_dose_from_edep_ct.py index 79633daf0..26e56f374 100755 --- a/opengate/tests/src/actors/test065_dose_from_edep_ct.py +++ b/opengate/tests/src/actors/test065_dose_from_edep_ct.py @@ -117,7 +117,12 @@ # physics sim.physics_manager.physics_list_name = "FTFP_INCLXX_EMZ" sim.physics_manager.set_production_cut("world", "all", 1000 * km) - sim.physics_manager.user_limits_particles = "all" + sim.physics_manager.user_limits_particles = [ + "proton", + "gamma", + "electron", + "positron", + ] # add dose actor dose_postprocess = sim.add_actor("DoseActor", "dose_postprocess") diff --git a/opengate/tests/src/actors/test081_pgtle_stage1.py b/opengate/tests/src/actors/test081_pgtle_stage1.py index 04d253aa9..6388d76eb 100755 --- a/opengate/tests/src/actors/test081_pgtle_stage1.py +++ b/opengate/tests/src/actors/test081_pgtle_stage1.py @@ -230,7 +230,7 @@ def voxel_to_mat_name(UH, mat_data): # from UH to material name sim.physics_manager.global_production_cuts.proton = 0.1 * mm sim.physics_manager.set_max_step_size("ct", 0.1 * mm) - sim.physics_manager.set_user_limits_particles(["proton"]) + sim.physics_manager.user_limits_particles = ["proton"] # source of proton # FIXME to replace by a more realistic proton beam, see tests 044 diff --git a/opengate/tests/src/actors/test081_tle_1_geom.py b/opengate/tests/src/actors/test081_tle_1_geom.py index 24996a2f0..18d1b1fb9 100755 --- a/opengate/tests/src/actors/test081_tle_1_geom.py +++ b/opengate/tests/src/actors/test081_tle_1_geom.py @@ -46,7 +46,7 @@ def main(argv): sim.physics_manager.physics_list_name = "G4EmStandardPhysics_option3" sim.physics_manager.global_production_cuts.all = 1 * mm sim.physics_manager.set_max_step_size("waterbox", 1 * mm) - sim.physics_manager.set_user_limits_particles("gamma") + sim.physics_manager.user_limits_particles = "gamma" s = f"/process/eLoss/CSDARange true" sim.g4_commands_before_init.append(s) diff --git a/opengate/tests/src/actors/test081_tle_4_high_energy.py b/opengate/tests/src/actors/test081_tle_4_high_energy.py index 1dee251f6..ff8e4b82f 100755 --- a/opengate/tests/src/actors/test081_tle_4_high_energy.py +++ b/opengate/tests/src/actors/test081_tle_4_high_energy.py @@ -46,7 +46,7 @@ sim.physics_manager.physics_list_name = "G4EmStandardPhysics_option3" sim.physics_manager.global_production_cuts.all = 1 * mm sim.physics_manager.set_max_step_size("waterbox", 1 * mm) - sim.physics_manager.set_user_limits_particles("gamma") + sim.physics_manager.user_limits_particles = "gamma" s = f"/process/eLoss/CSDARange true" sim.g4_commands_before_init.append(s) diff --git a/opengate/tests/src/actors/test081_tle_5_db.py b/opengate/tests/src/actors/test081_tle_5_db.py index 3eed322b4..c001c18ef 100755 --- a/opengate/tests/src/actors/test081_tle_5_db.py +++ b/opengate/tests/src/actors/test081_tle_5_db.py @@ -46,7 +46,7 @@ sim.physics_manager.physics_list_name = "G4EmStandardPhysics_option3" sim.physics_manager.global_production_cuts.all = 1 * mm sim.physics_manager.set_max_step_size("waterbox", 1 * mm) - sim.physics_manager.set_user_limits_particles("gamma") + sim.physics_manager.user_limits_particles = "gamma" # default source for tests source = add_source(sim, n=1e4, energy=0.3 * MeV, sigma=0.2 * MeV, radius=20 * mm) diff --git a/opengate/tests/src/actors/test081_tle_6_brem_splitting.py b/opengate/tests/src/actors/test081_tle_6_brem_splitting.py index e9c33f773..be1e9d9d1 100755 --- a/opengate/tests/src/actors/test081_tle_6_brem_splitting.py +++ b/opengate/tests/src/actors/test081_tle_6_brem_splitting.py @@ -72,7 +72,7 @@ def main(argv): sim.physics_manager.physics_list_name = "G4EmStandardPhysics_option3" sim.physics_manager.global_production_cuts.all = 0.001 * mm sim.physics_manager.set_max_step_size("waterbox", 0.1 * mm) - sim.physics_manager.set_user_limits_particles("gamma") + sim.physics_manager.user_limits_particles = "gamma" sim.volume_manager.material_database.add_material_weights( "Tungsten", diff --git a/opengate/tests/src/physics/test053_step_limiter_mt.py b/opengate/tests/src/physics/test053_step_limiter_mt.py index e871d5f5f..43304c002 100755 --- a/opengate/tests/src/physics/test053_step_limiter_mt.py +++ b/opengate/tests/src/physics/test053_step_limiter_mt.py @@ -24,7 +24,7 @@ def simulate(number_of_threads=1, start_new_process=False): requested_stepsizes = {} requested_minekine = {} - sim.physics_manager.set_user_limits_particles("gamma") + sim.physics_manager.user_limits_particles = "gamma" # *** Step size in a single volume *** waterbox_A = sim.add_volume("Box", "waterbox_A") diff --git a/opengate/tests/src/source/test059_tpsource_LUT_beammodel.py b/opengate/tests/src/source/test059_tpsource_LUT_beammodel.py index 21e6c3e41..f3d48093b 100755 --- a/opengate/tests/src/source/test059_tpsource_LUT_beammodel.py +++ b/opengate/tests/src/source/test059_tpsource_LUT_beammodel.py @@ -84,7 +84,7 @@ def filter_outliers(v_data): sim.physics_manager.physics_list_name = "QGSP_BIC_HP_EMZ" sim.physics_manager.set_max_step_size(x_0.name, 1.0) - sim.physics_manager.set_user_limits_particles("proton") + sim.physics_manager.user_limits_particles = "proton" # sim.physics_manager.user_limits_particles = ['proton','GenericIon'] sim.physics_manager.set_production_cut("world", "gamma", 100 * m) diff --git a/opengate/tests/src/source/test059_tpsource_optics.py b/opengate/tests/src/source/test059_tpsource_optics.py index 9f04b8733..c0ce607f1 100755 --- a/opengate/tests/src/source/test059_tpsource_optics.py +++ b/opengate/tests/src/source/test059_tpsource_optics.py @@ -66,7 +66,12 @@ phantom.material = "G4_AIR" phantom.color = [0, 0, 1, 1] sim.physics_manager.set_max_step_size(phantom.name, 0.8) - sim.physics_manager.set_user_limits_particles("all") + sim.physics_manager.user_limits_particles = [ + "proton", + "gamma", + "electron", + "positron", + ] # physics sim.physics_manager.physics_list_name = "FTFP_INCLXX_EMZ" From 80f84f62613a702ba0a3085bca4ba23ff2985d73 Mon Sep 17 00:00:00 2001 From: Nils Krah Date: Thu, 21 May 2026 21:12:33 +0200 Subject: [PATCH 10/15] Document step limiter implementation --- .../developer_guide_physics.rst | 161 ++++++++++++++++++ docs/source/developer_guide/index.rst | 6 + 2 files changed, 167 insertions(+) create mode 100644 docs/source/developer_guide/developer_guide_physics.rst diff --git a/docs/source/developer_guide/developer_guide_physics.rst b/docs/source/developer_guide/developer_guide_physics.rst new file mode 100644 index 000000000..be31e8b8c --- /dev/null +++ b/docs/source/developer_guide/developer_guide_physics.rst @@ -0,0 +1,161 @@ +Physics +======= + +This chapter documents implementation details of GATE's physics handling for +developers. It focuses on the Python-side orchestration around Geant4 physics +objects, i.e. the code that turns user-facing settings into Geant4 regions, +physics constructors, processes, and cuts. + +Step Limiter +------------ + +The step limiter is implemented through Geant4 user limits. On the GATE side, +the relevant code is split across three places: + +* ``opengate.managers.PhysicsManager`` exposes the user-facing API. +* ``opengate.physics.Region`` stores per-region user limits and builds the + corresponding Geant4 region objects. +* ``opengate.physics.UserLimitsPhysics`` is a Geant4 physics constructor that + adds the required Geant4 processes to the selected particles. + +User-facing entry points +~~~~~~~~~~~~~~~~~~~~~~~~ + +Users set the maximum step size either through the physics manager: + +.. code-block:: python + + sim.physics_manager.set_max_step_size(volume.name, 1 * gate.g4_units.mm) + +or through the volume convenience method: + +.. code-block:: python + + volume.set_max_step_size(1 * gate.g4_units.mm) + +Internally, both paths associate the volume with a ``Region`` object and store +the value in ``region.user_limits["max_step_size"]``. If no region exists yet +for the volume, ``PhysicsManager.find_or_create_region()`` creates one. The +world volume is associated with Geant4's ``DefaultRegionForTheWorld``; other +volumes use a GATE-created region named ``_region`` by default. + +The particles to which user limits are applied are selected with: + +.. code-block:: python + + sim.physics_manager.user_limits_particles = ["proton", "GenericIon"] + +This setting is a list-like user input. A single string is accepted by the +setter hook and converted to a one-element list. The special values are: + +* ``"all"``: apply user limits to all Geant4 particles known to the particle + table. +* ``"all_charged"``: add the step limiter to charged particles, following + Geant4's ``G4StepLimiterPhysics`` behaviour. + +Particle names are intentionally not limited to the production-cut aliases +(``gamma``, ``electron``, ``positron``, ``proton``). Any Geant4 particle name is +accepted, after applying the small GATE-to-Geant4 alias translation implemented +by ``translate_particle_name_gate_to_geant4()``. + +Region initialization +~~~~~~~~~~~~~~~~~~~~~ + +During simulation initialization, every ``Region`` runs +``Region.initialize_g4_user_limits()``. If none of the user-limit fields is set, +no ``G4UserLimits`` object is created. Otherwise, GATE creates one and fills all +limits: + +* ``max_step_size`` maps to ``G4UserLimits.SetMaxAllowedStep()``. +* ``max_track_length`` maps to ``SetUserMaxTrackLength()``. +* ``max_time`` maps to ``SetUserMaxTime()``. +* ``min_ekine`` maps to ``SetUserMinEkine()``. +* ``min_range`` maps to ``SetUserMinRange()``. + +Unset upper limits are replaced by ``FLOAT_MAX`` and unset lower limits by +``0``. This lets one ``G4UserLimits`` object represent only the limits that the +user actually requested. + +After that, ``Region.initialize_g4_region()`` creates or finds the Geant4 +``G4Region``, attaches the ``G4UserLimits`` object to it, and adds the root +logical volumes associated with the GATE region. This is the part that tells +Geant4 where the user-limit values apply. + +Registering the Geant4 processes +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A ``G4UserLimits`` object alone is not sufficient. Geant4 also needs tracking +processes attached to particles so that the limits are actually enforced. + +The physics engine checks all regions in +``PhysicsEngine.initialize_user_limits_physics()``: + +.. code-block:: python + + if region.need_step_limiter(): + need_step_limiter = True + if region.need_user_special_cut(): + need_user_special_cut = True + +If at least one region needs a maximum step size or another user special cut, +GATE registers ``UserLimitsPhysics`` on the active Geant4 physics list. + +``UserLimitsPhysics.ConstructProcess()`` then iterates over the full Geant4 +particle table and decides, particle by particle, whether to add: + +* ``G4StepLimiter("StepLimiter")`` for ``max_step_size``. +* ``G4UserSpecialCuts("UserSpecialCut")`` for the other user limits. + +For explicit particle names and for ``"all"``, GATE adds both processes. For +``"all_charged"``, GATE only adds ``G4StepLimiter`` to charged particles. This +mirrors Geant4's default step-limiter constructor behaviour and avoids applying +the other special cuts unless the user explicitly asked for the particle. + +The created Geant4 process objects are stored in +``UserLimitsPhysics.g4_step_limiter_storage`` and +``UserLimitsPhysics.g4_special_user_cuts_storage``. This storage is important: +the objects are created from Python via pybind11, and keeping references avoids +their garbage collection after ``ConstructProcess()`` returns. + +Validation and name translation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Before processes are added, ``UserLimitsPhysics`` validates all explicitly named +particles against ``G4ParticleTable``. Unknown particles are rejected with a +fatal error. The special selectors ``"all"`` and ``"all_charged"`` are removed +before validation. + +The translation helper ``translate_particle_name_gate_to_geant4()`` maps the +historical GATE names used for production cuts to Geant4 names: + +* ``electron`` -> ``e-`` +* ``positron`` -> ``e+`` +* ``gamma`` -> ``gamma`` +* ``proton`` -> ``proton`` + +Names not present in this alias table are passed through unchanged. This is what +allows inputs such as ``"GenericIon"`` or other Geant4 particle names to work +without extending the production-cut particle list. + +Important distinction from production cuts +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Production cuts and user limits have different particle-name scopes in GATE. +Production cuts are still constrained to the particle aliases in +``cut_particle_names`` because that mirrors Geant4 production-cut usage in GATE. +User limits, including the step limiter, are more general: Geant4 can apply them +to any particle with a process manager. + +This distinction is why ``sim.physics_manager.user_limits_particles = "all"`` +means all Geant4 particles for user limits. Tests that need the historical +GATE behaviour should explicitly request: + +.. code-block:: python + + sim.physics_manager.user_limits_particles = [ + "proton", + "gamma", + "electron", + "positron", + ] + diff --git a/docs/source/developer_guide/index.rst b/docs/source/developer_guide/index.rst index f1accde0a..332e35ff8 100644 --- a/docs/source/developer_guide/index.rst +++ b/docs/source/developer_guide/index.rst @@ -18,6 +18,12 @@ Developer guide developer_guide_dynamic_parametrisations developer_guide_auxiliary_attributes +.. toctree:: + :caption: Physics + :maxdepth: 2 + + developer_guide_physics + .. toctree:: :caption: Good to know :maxdepth: 2 From 21a9ee58ad10f17dec59ae6d516b545ac32cadb1 Mon Sep 17 00:00:00 2001 From: Nils Krah Date: Tue, 30 Jun 2026 21:10:12 +0200 Subject: [PATCH 11/15] adapt test023 to API change --- .../actors/test023_interaction_counter_auxiliary_attribute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opengate/tests/src/actors/test023_interaction_counter_auxiliary_attribute.py b/opengate/tests/src/actors/test023_interaction_counter_auxiliary_attribute.py index 953576233..70bc46a8b 100755 --- a/opengate/tests/src/actors/test023_interaction_counter_auxiliary_attribute.py +++ b/opengate/tests/src/actors/test023_interaction_counter_auxiliary_attribute.py @@ -69,7 +69,7 @@ def run_simulation( sim.physics_manager.physics_list_name = "G4EmStandardPhysics_option4" if max_step_size is not None: sim.physics_manager.set_max_step_size(slab.name, max_step_size) - sim.physics_manager.set_user_limits_particles("gamma") + sim.physics_manager.user_limits_particles = "gamma" sim.run(start_new_process=True) tree = uproot.open(phsp.get_output_path())["phsp"] From 2477861e34d6f2b8a7d2edad8aee7da21dee0bbd Mon Sep 17 00:00:00 2001 From: Nils Krah Date: Wed, 1 Jul 2026 13:14:14 +0200 Subject: [PATCH 12/15] adapt test059* to changed step limiter API --- .../src/source/test059_tpsource_LUT_beammodel.py | 6 +++--- .../tests/src/source/test059_tpsource_optics.py | 15 ++++++++------- .../src/source/test059_tpsource_optics_vbl.py | 3 ++- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/opengate/tests/src/source/test059_tpsource_LUT_beammodel.py b/opengate/tests/src/source/test059_tpsource_LUT_beammodel.py index f3d48093b..f095f5313 100755 --- a/opengate/tests/src/source/test059_tpsource_LUT_beammodel.py +++ b/opengate/tests/src/source/test059_tpsource_LUT_beammodel.py @@ -83,9 +83,9 @@ def filter_outliers(v_data): # physics sim.physics_manager.physics_list_name = "QGSP_BIC_HP_EMZ" - sim.physics_manager.set_max_step_size(x_0.name, 1.0) - sim.physics_manager.user_limits_particles = "proton" - # sim.physics_manager.user_limits_particles = ['proton','GenericIon'] + x_0.set_max_step_size(1.0 * mm) + # sim.physics_manager.user_limits_particles = "proton" + sim.physics_manager.user_limits_particles = ['proton','GenericIon'] sim.physics_manager.set_production_cut("world", "gamma", 100 * m) sim.physics_manager.set_production_cut("world", "electron", 100 * m) diff --git a/opengate/tests/src/source/test059_tpsource_optics.py b/opengate/tests/src/source/test059_tpsource_optics.py index c0ce607f1..9fd4935b4 100755 --- a/opengate/tests/src/source/test059_tpsource_optics.py +++ b/opengate/tests/src/source/test059_tpsource_optics.py @@ -65,13 +65,14 @@ # phantom.rotation = Rotation.from_euler('x',-90,degrees=True).as_matrix() phantom.material = "G4_AIR" phantom.color = [0, 0, 1, 1] - sim.physics_manager.set_max_step_size(phantom.name, 0.8) - sim.physics_manager.user_limits_particles = [ - "proton", - "gamma", - "electron", - "positron", - ] + phantom.set_max_step_size(0.8 * mm) + sim.physics_manager.user_limits_particles = "all" + # sim.physics_manager.user_limits_particles = [ + # "proton", + # "gamma", + # "electron", + # "positron", + # ] # physics sim.physics_manager.physics_list_name = "FTFP_INCLXX_EMZ" diff --git a/opengate/tests/src/source/test059_tpsource_optics_vbl.py b/opengate/tests/src/source/test059_tpsource_optics_vbl.py index 6c369a5d7..e408b8893 100755 --- a/opengate/tests/src/source/test059_tpsource_optics_vbl.py +++ b/opengate/tests/src/source/test059_tpsource_optics_vbl.py @@ -68,7 +68,8 @@ phantom.translation = [0.0, -150 * mm, 0.0] phantom.material = "G4_AIR" phantom.color = [0, 0, 1, 1] - sim.physics_manager.set_max_step_size(phantom.name, 0.8) + phantom.set_max_step_size(0.8 * mm) + sim.physics_manager.user_limits_particles = "all" # physics sim.physics_manager.physics_list_name = "FTFP_INCLXX_EMZ" From c3d6d5abf12bd015e32d2308f1e4434f9cf59f31 Mon Sep 17 00:00:00 2001 From: Nils Krah Date: Wed, 1 Jul 2026 13:15:03 +0200 Subject: [PATCH 13/15] make separate output folders for pileup tests --- opengate/tests/src/actors/test097_pileup.py | 4 +++- opengate/tests/src/actors/test097_pileup_mt.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/opengate/tests/src/actors/test097_pileup.py b/opengate/tests/src/actors/test097_pileup.py index 3107365a5..a05aaf3ef 100755 --- a/opengate/tests/src/actors/test097_pileup.py +++ b/opengate/tests/src/actors/test097_pileup.py @@ -12,7 +12,9 @@ from test097_pileup_simulation import create_simulation if __name__ == "__main__": - paths = utility.get_default_test_paths(__file__, "gate_test097", "test097") + paths = utility.get_default_test_paths( + __file__, "gate_test097", "test097_pileup" + ) sim, pu, root_filename = create_simulation(paths, num_threads=1) diff --git a/opengate/tests/src/actors/test097_pileup_mt.py b/opengate/tests/src/actors/test097_pileup_mt.py index 489e302a9..9531ae3ce 100755 --- a/opengate/tests/src/actors/test097_pileup_mt.py +++ b/opengate/tests/src/actors/test097_pileup_mt.py @@ -12,7 +12,9 @@ from test097_pileup_simulation import create_simulation if __name__ == "__main__": - paths = utility.get_default_test_paths(__file__, "gate_test097", "test097") + paths = utility.get_default_test_paths( + __file__, "gate_test097", "test097_pileup_mt" + ) sim, pu, root_filename = create_simulation(paths, num_threads=2) From 8a4e6f3bbb92243d34616fe0422d935a4b43d5ae Mon Sep 17 00:00:00 2001 From: Nils Krah Date: Wed, 1 Jul 2026 13:22:19 +0200 Subject: [PATCH 14/15] Fix dependency handling in test097b_ccmod_coincidences.py --- .../coresi/test097b_ccmod_coincidences.py | 43 ++++++++++++++++--- 1 file changed, 38 insertions(+), 5 deletions(-) diff --git a/opengate/tests/src/external/coresi/test097b_ccmod_coincidences.py b/opengate/tests/src/external/coresi/test097b_ccmod_coincidences.py index 0b85bf719..71221a202 100755 --- a/opengate/tests/src/external/coresi/test097b_ccmod_coincidences.py +++ b/opengate/tests/src/external/coresi/test097b_ccmod_coincidences.py @@ -1,29 +1,48 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- +import os +import subprocess + from opengate.utility import g4_units import opengate.tests.utility as utility from opengate.actors.coincidences import * import opengate.contrib.compton_camera.macaco as macaco import opengate.contrib.compton_camera.coresi_helpers as ch -if __name__ == "__main__": +def main(dependency="test097a_ccmod_simulation.py"): # get tests paths paths = utility.get_default_test_paths( __file__, gate_folder="", output_folder="test097_coresi_ccmod" ) output_folder = paths.output - data_folder = paths.data # units - mm = g4_units.mm - cm = g4_units.cm ns = g4_units.ns - # compute the coincidences scatt_file = output_folder / "ThrScatt.root" abs_file = output_folder / "ThrAbs.root" + + # The test needs the output of the simulation step. + # When this script is launched via the opengate_tests suite, the + # dependency="test097a_ccmod_simulation.py" signature on main() is used by + # the runner to schedule test097a before test097b. The fallback below is + # only for standalone execution of this script. + if not scatt_file.exists() or not abs_file.exists(): + subdir = os.path.dirname(__file__) + print(f"Missing input singles files, running {dependency} first.") + subprocess.call(["python", paths.current / subdir / dependency]) + + if not scatt_file.exists() or not abs_file.exists(): + utility.test_ok( + False, + exceptions=[ + f"Required input files were not created: {scatt_file.name}, {abs_file.name}" + ], + ) + + # compute the coincidences print(f"Computing coincidences from {scatt_file.name} and {abs_file.name}") merge_file = output_folder / "singles.root" coinc_file = output_folder / "coincidences.root" @@ -65,3 +84,17 @@ data_filename = output_folder / "coincidences.dat" ch.coresi_convert_root_data(cones_filename, "cones", data_filename) print(f"Data file: {data_filename.name}") + + is_ok = ( + merge_file.exists() + and coinc_file.exists() + and cones_filename.exists() + and data_filename.exists() + and len(coincidences) > 0 + and len(data_cones) > 0 + ) + utility.test_ok(is_ok) + + +if __name__ == "__main__": + main() From 790067bcf167146bab59cf0546c79dd059c52055 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:25:28 +0000 Subject: [PATCH 15/15] [pre-commit.ci] Automatic python and c++ formatting --- opengate/tests/src/actors/test097_pileup.py | 4 +--- opengate/tests/src/source/test059_tpsource_LUT_beammodel.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/opengate/tests/src/actors/test097_pileup.py b/opengate/tests/src/actors/test097_pileup.py index a05aaf3ef..2437ad7f2 100755 --- a/opengate/tests/src/actors/test097_pileup.py +++ b/opengate/tests/src/actors/test097_pileup.py @@ -12,9 +12,7 @@ from test097_pileup_simulation import create_simulation if __name__ == "__main__": - paths = utility.get_default_test_paths( - __file__, "gate_test097", "test097_pileup" - ) + paths = utility.get_default_test_paths(__file__, "gate_test097", "test097_pileup") sim, pu, root_filename = create_simulation(paths, num_threads=1) diff --git a/opengate/tests/src/source/test059_tpsource_LUT_beammodel.py b/opengate/tests/src/source/test059_tpsource_LUT_beammodel.py index f095f5313..17b03e7c0 100755 --- a/opengate/tests/src/source/test059_tpsource_LUT_beammodel.py +++ b/opengate/tests/src/source/test059_tpsource_LUT_beammodel.py @@ -85,7 +85,7 @@ def filter_outliers(v_data): x_0.set_max_step_size(1.0 * mm) # sim.physics_manager.user_limits_particles = "proton" - sim.physics_manager.user_limits_particles = ['proton','GenericIon'] + sim.physics_manager.user_limits_particles = ["proton", "GenericIon"] sim.physics_manager.set_production_cut("world", "gamma", 100 * m) sim.physics_manager.set_production_cut("world", "electron", 100 * m)