Coverage for yaptide/converter/converter/shieldhit/parser.py: 69%

264 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-07-01 12:55 +0000

1import itertools 

2from typing import Optional 

3import re 

4 

5import converter.solid_figures as solid_figures 

6from converter.common import Parser 

7from converter.shieldhit.beam import (BeamConfig, BeamModulator, BeamSourceType, ModulatorInterpretationMode, 

8 ModulatorSimulationMethod, MultipleScatteringMode, StragglingModel) 

9from converter.shieldhit.detect import (DetectConfig, OutputQuantity, ScoringFilter, ScoringOutput, QuantitySettings) 

10from converter.shieldhit.geo import (DefaultMaterial, GeoMatConfig, Material, Zone, StoppingPowerFile) 

11from converter.shieldhit.detectors import (ScoringCylinder, ScoringDetector, ScoringGlobal, ScoringMesh, ScoringZone) 

12 

13_particle_dict: dict[int, dict] = { 

14 1: { 

15 'name': 'NEUTRON', 

16 'filter': [ 

17 ('Z', '==', 0), 

18 ('A', '==', 1), 

19 ] 

20 }, 

21 2: { 

22 'name': 'PROTON', 

23 'filter': [('Z', '==', 1), ('A', '==', 1)] 

24 }, 

25 3: { 

26 'name': 'PION-', 

27 'filter': [('ID', '==', 3)] 

28 }, 

29 4: { 

30 'name': 'PION+', 

31 'filter': [('ID', '==', 4)] 

32 }, 

33 5: { 

34 'name': 'PIZERO', 

35 'filter': [('ID', '==', 5)] 

36 }, 

37 # 6: { 

38 # 'name': 'ANEUTRON', 

39 # 'a': 1 

40 # }, 

41 7: { 

42 'name': 'APROTON', 

43 'filter': [('Z', '==', -1), ('A', '==', 3)] 

44 }, 

45 8: { 

46 'name': 'KAON-', 

47 'filter': [('ID', '==', 8)] 

48 }, 

49 9: { 

50 'name': 'KAON+', 

51 'filter': [('ID', '==', 9)] 

52 }, 

53 10: { 

54 'name': 'KAONZERO', 

55 'filter': [('ID', '==', 10)] 

56 }, 

57 11: { 

58 'name': 'KAONLONG', 

59 'filter': [('ID', '==', 11)] 

60 }, 

61 15: { 

62 'name': 'MUON-', 

63 'filter': [('ID', '==', 15)] 

64 }, 

65 16: { 

66 'name': 'MUON+', 

67 'filter': [('ID', '==', 16)] 

68 }, 

69 21: { 

70 'name': 'DEUTERON', 

71 'filter': [('Z', '==', 1), ('A', '==', 2)] 

72 }, 

73 22: { 

74 'name': 'TRITON', 

75 'filter': [('Z', '==', 1), ('A', '==', 3)] 

76 }, 

77 23: { 

78 'name': '3-HELIUM', 

79 'filter': [('Z', '==', 2), ('A', '==', 3)] 

80 }, 

81 24: { 

82 'name': '4-HELIUM', 

83 'filter': [('Z', '==', 2), ('A', '==', 4)] 

84 } 

85} 

86 

87 

88def parse_scoring_filter(scoring_filter: dict) -> ScoringFilter: 

89 """Parse scoring filter from JSON 

90 

91 Generates a ScoringFilter object from a JSON dictionary. 

92 """ 

93 if scoring_filter.get("particle"): 

94 # If the filter is a particle filter, we want to map it to format used by SHIELD-HIT12A 

95 return ScoringFilter(uuid=scoring_filter["uuid"], 

96 name=scoring_filter["name"], 

97 rules=_particle_dict[scoring_filter["particle"]["id"]]['filter']) 

98 

99 return ScoringFilter(uuid=scoring_filter["uuid"], 

100 name=scoring_filter["name"], 

101 rules=[(rule_dict["keyword"], rule_dict["operator"], rule_dict["value"]) 

102 for rule_dict in scoring_filter["rules"]]) 

103 

104 

105class ShieldhitParser(Parser): 

106 """A SHIELD-HIT12A parser""" 

107 

108 def __init__(self) -> None: 

109 super().__init__() 

110 self.info['simulator'] = 'shieldhit' 

111 self.beam_config = BeamConfig() 

112 self.detect_config = DetectConfig() 

113 self.geo_mat_config = GeoMatConfig() 

114 

115 def parse_configs(self, json: dict) -> None: 

116 """Wrapper for all parse functions""" 

117 self._parse_geo_mat(json) 

118 self._parse_beam(json) 

119 self._parse_detect(json) 

120 

121 def parse_modulator(self, json: dict) -> None: 

122 """Parses data from the input json into the beam_config property""" 

123 if json["specialComponentsManager"].get("modulator") is not None: 

124 modulator = json["specialComponentsManager"].get("modulator") 

125 parameters = modulator['geometryData'].get('parameters') 

126 sourceFile = modulator.get('sourceFile') 

127 zone_id = self._get_zone_index_by_uuid(parameters["zoneUuid"]) 

128 if sourceFile is not None and zone_id is not None: 

129 if sourceFile.get('name') is None or sourceFile.get('value') is None: 

130 raise ValueError("Modulator source file name or content is not defined") 

131 self.beam_config.modulator = BeamModulator( 

132 filename=sourceFile.get('name'), 

133 file_content=sourceFile.get('value'), 

134 zone_id=zone_id, 

135 simulation=ModulatorSimulationMethod.from_str(modulator.get('simulationMethod', 'modulus')), 

136 mode=ModulatorInterpretationMode.from_str(modulator.get('interpretationMode', 'material'))) 

137 

138 def parse_physics(self, json: dict) -> None: 

139 """Parses data from the input json into the beam_config property""" 

140 if json.get("physic") is not None: 

141 self.beam_config.delta_e = json["physic"].get("energyLoss", self.beam_config.delta_e) 

142 self.beam_config.nuclear_reactions = json["physic"].get("enableNuclearReactions", 

143 self.beam_config.nuclear_reactions) 

144 self.beam_config.straggling = StragglingModel.from_str(json["physic"].get( 

145 "energyModelStraggling", self.beam_config.straggling.value)) 

146 self.beam_config.multiple_scattering = MultipleScatteringMode.from_str(json["physic"].get( 

147 "multipleScattering", self.beam_config.multiple_scattering.value)) 

148 

149 def _parse_beam(self, json: dict) -> None: 

150 """Parses data from the input json into the beam_config property""" 

151 self.beam_config.particle = json["beam"]["particle"]["id"] 

152 self.beam_config.particle_name = json["beam"]["particle"].get("name") 

153 self.beam_config.heavy_ion_a = json["beam"]["particle"]["a"] 

154 self.beam_config.heavy_ion_z = json["beam"]["particle"]["z"] 

155 self.beam_config.energy = json["beam"]["energy"] 

156 self.beam_config.energy_spread = json["beam"]["energySpread"] 

157 # we use get here to avoid KeyError if the cutoffs are not defined 

158 # in that case None will be inserted into the beam config 

159 # which is well handled by the converter 

160 self.beam_config.energy_low_cutoff = json["beam"].get("energyLowCutoff") 

161 self.beam_config.energy_high_cutoff = json["beam"].get("energyHighCutoff") 

162 self.beam_config.n_stat = json["beam"].get("numberOfParticles", self.beam_config.n_stat) 

163 self.beam_config.beam_pos = tuple(json["beam"]["position"]) 

164 self.beam_config.beam_dir = tuple(json["beam"]["direction"]) 

165 

166 if json["beam"].get("sigma") is not None: 

167 beam_type = json["beam"]["sigma"]["type"] 

168 

169 if beam_type == "Gaussian": 

170 self.beam_config.beam_ext_x = abs(json["beam"]["sigma"]["x"]) 

171 self.beam_config.beam_ext_y = abs(json["beam"]["sigma"]["y"]) 

172 elif beam_type == "Flat square": 

173 self.beam_config.beam_ext_x = -abs(json["beam"]["sigma"]["x"]) 

174 self.beam_config.beam_ext_y = -abs(json["beam"]["sigma"]["y"]) 

175 elif beam_type == "Flat circular": 

176 # To generate a circular beam x value must be greater than 0 

177 self.beam_config.beam_ext_x = 1.0 

178 self.beam_config.beam_ext_y = -abs(json["beam"]["sigma"]["y"]) 

179 

180 if json["beam"].get("sad") is not None: 

181 beam_type = json["beam"]["sad"]["type"] 

182 

183 if beam_type == "double": 

184 self.beam_config.sad_x = json["beam"]["sad"]["x"] 

185 self.beam_config.sad_y = json["beam"]["sad"]["y"] 

186 elif beam_type == "single": 

187 self.beam_config.sad_x = json["beam"]["sad"]["x"] 

188 self.beam_config.sad_y = None 

189 else: 

190 self.beam_config.sad_x = None 

191 self.beam_config.sad_y = None 

192 

193 if json["beam"].get("sourceType", "") == BeamSourceType.FILE.label: 

194 self.beam_config.beam_source_type = BeamSourceType.FILE 

195 if "sourceFile" in json["beam"]: 

196 self.beam_config.beam_source_filename = json["beam"]["sourceFile"].get("name") 

197 self.beam_config.beam_source_file_content = json["beam"]["sourceFile"].get("value") 

198 

199 self.parse_physics(json) 

200 self.parse_modulator(json) 

201 

202 def _parse_detect(self, json: dict) -> None: 

203 """Parses data from the input json into the detect_config property""" 

204 self.detect_config.detectors = self._parse_detectors(json) 

205 self.detect_config.filters = self._parse_filters(json) 

206 self.detect_config.outputs = self._parse_outputs(json) 

207 

208 def _parse_detectors(self, json: dict) -> list[ScoringDetector]: 

209 """Parses detectors from the input json.""" 

210 detectors = [] 

211 for detector_dict in json["detectorManager"].get("detectors"): 

212 geometry_type = detector_dict['geometryData'].get('geometryType') 

213 position = detector_dict['geometryData'].get('position') 

214 parameters = detector_dict['geometryData'].get('parameters') 

215 if geometry_type == "Cyl": 

216 detectors.append( 

217 ScoringCylinder( 

218 uuid=detector_dict["uuid"], 

219 name=detector_dict["name"], 

220 r_min=parameters["innerRadius"], 

221 r_max=parameters["radius"], 

222 r_bins=parameters["radialSegments"], 

223 h_min=position[2] - parameters["depth"] / 2, 

224 h_max=position[2] + parameters["depth"] / 2, 

225 h_bins=parameters["zSegments"], 

226 )) 

227 

228 elif geometry_type == "Mesh": 

229 detectors.append( 

230 ScoringMesh( 

231 uuid=detector_dict["uuid"], 

232 name=detector_dict["name"], 

233 x_min=position[0] - parameters["width"] / 2, 

234 x_max=position[0] + parameters["width"] / 2, 

235 x_bins=parameters["xSegments"], 

236 y_min=position[1] - parameters["height"] / 2, 

237 y_max=position[1] + parameters["height"] / 2, 

238 y_bins=parameters["ySegments"], 

239 z_min=position[2] - parameters["depth"] / 2, 

240 z_max=position[2] + parameters["depth"] / 2, 

241 z_bins=parameters["zSegments"], 

242 )) 

243 

244 elif geometry_type == "Zone": 

245 detectors.append( 

246 ScoringZone( 

247 uuid=detector_dict["uuid"], 

248 name=detector_dict["name"], 

249 first_zone_id=self._get_zone_index_by_uuid(parameters["zoneUuid"]), 

250 )) 

251 

252 elif geometry_type == "All": 

253 detectors.append(ScoringGlobal( 

254 uuid=detector_dict["uuid"], 

255 name=detector_dict["name"], 

256 )) 

257 else: 

258 raise ValueError(f"Invalid ScoringGeometry type: {detector_dict['type']}") 

259 

260 return detectors 

261 

262 def _get_zone_index_by_uuid(self, zone_uuid: str) -> int: 

263 """Finds zone in the geo_mat_config object by its uuid and returns its simulation index.""" 

264 for idx, zone in enumerate(self.geo_mat_config.zones): 

265 if zone.uuid == zone_uuid: 

266 return idx + 1 

267 

268 raise ValueError(f"No zone with uuid \"{zone_uuid}\".") 

269 

270 @staticmethod 

271 def _parse_filters(json: dict) -> list[ScoringFilter]: 

272 """Parses scoring filters from the input json.""" 

273 filters = [parse_scoring_filter(filter_dict) for filter_dict in json["scoringManager"]["filters"]] 

274 

275 return filters 

276 

277 def _parse_outputs(self, json: dict) -> list[ScoringOutput]: 

278 """Parses scoring outputs from the input json.""" 

279 outputs = [ 

280 ScoringOutput( 

281 filename=output_dict["name"] + ".bdo", 

282 fileformat=output_dict["fileFormat"] if "fileFormat" in output_dict else "", 

283 geometry=self._get_detector_by_uuid(output_dict["detectorUuid"]) 

284 if 'detectorUuid' in output_dict else None, 

285 quantities=[self._parse_output_quantity(quantity) for quantity in output_dict.get("quantities", [])], 

286 ) for output_dict in json["scoringManager"]["outputs"] 

287 ] 

288 

289 return outputs 

290 

291 def _get_detector_by_uuid(self, detect_uuid: str) -> Optional[str]: 

292 """Finds detector in the detect_config object by its uuid and returns its simulation name.""" 

293 for detector in self.detect_config.detectors: 

294 if detector.uuid == detect_uuid: 

295 return detector.name 

296 

297 raise ValueError(f"No detector with uuid {detect_uuid}") 

298 

299 def _parse_quantity_settings(self, quantity_dict: dict) -> dict or None: 

300 """Parses settings from the input json into the quantity settings property""" 

301 

302 def create_name_from_settings() -> str: 

303 """Create a name for the quantity from its settings.""" 

304 # If the quantity has generic name in format [Quantity_XYZ], we want to use more descriptive name 

305 # New name will be in format [Absolute/Rescaled]_[Quantity_XYZ]_[QuantityKeyword]_[to_Medium/to_Material] 

306 # Specific elements of the name will be added only if they are present in the settings 

307 if re.search(r'^Quantity(_\d*)?$', quantity_dict['name']): 

308 prefix = '' 

309 suffix = '' 

310 if 'primaries' in quantity_dict: 

311 prefix = 'Absolute_' 

312 elif 'rescale' in quantity_dict: 

313 prefix = 'Rescaled_' 

314 if 'medium' in quantity_dict: 

315 suffix = f'_to_{quantity_dict["medium"]}' 

316 elif 'materialUuid' in quantity_dict: 

317 suffix = f'_to_{self._get_material_by_uuid(quantity_dict["materialUuid"]).sanitized_name}' 

318 result = f"{prefix}{quantity_dict['keyword']}_{quantity_dict['name']}{suffix}" 

319 return result 

320 

321 # If the quantity has a custom name, we want to remove all non-alphanumeric characters from it 

322 return re.sub(r'\W+', '', quantity_dict['name']) 

323 

324 # We want to skip parsing settings if there are no parameters to put in the settings 

325 if all(map(lambda el: el not in quantity_dict, ['medium', 'offset', 'primaries', 'materialUuid', 'rescale'])): 

326 return None 

327 

328 return QuantitySettings( 

329 name=create_name_from_settings(), 

330 medium=quantity_dict.get("medium", None), 

331 offset=quantity_dict.get("offset", None), 

332 primaries=quantity_dict.get("primaries", None), 

333 rescale=quantity_dict.get("rescale", None), 

334 material=self._get_material_id(quantity_dict["materialUuid"]) if 'materialUuid' in quantity_dict else None) 

335 

336 def _parse_output_quantity(self, quantity_dict: dict) -> OutputQuantity: 

337 """Parse a single output quantity.""" 

338 self._parse_custom_material(quantity_dict) 

339 diff1 = None 

340 diff1_t = None 

341 diff2 = None 

342 diff2_t = None 

343 

344 if len(quantity_dict["modifiers"]) >= 1: 

345 diff1 = ( 

346 quantity_dict["modifiers"][0]["lowerLimit"], 

347 quantity_dict["modifiers"][0]["upperLimit"], 

348 quantity_dict["modifiers"][0]["binsNumber"], 

349 quantity_dict["modifiers"][0]["isLog"], 

350 ) 

351 diff1_t = quantity_dict["modifiers"][0]["diffType"] 

352 

353 if len(quantity_dict["modifiers"]) >= 2: 

354 diff2 = ( 

355 quantity_dict["modifiers"][1]["lowerLimit"], 

356 quantity_dict["modifiers"][1]["upperLimit"], 

357 quantity_dict["modifiers"][1]["binsNumber"], 

358 quantity_dict["modifiers"][1]["isLog"], 

359 ) 

360 diff2_t = quantity_dict["modifiers"][1]["diffType"] 

361 

362 return OutputQuantity( 

363 name=quantity_dict["name"], 

364 detector_type=quantity_dict["keyword"], 

365 filter_name=self._get_scoring_filter_by_uuid(quantity_dict["filter"]) if "filter" in quantity_dict else "", 

366 diff1=diff1, 

367 diff1_t=diff1_t, 

368 diff2=diff2, 

369 diff2_t=diff2_t, 

370 settings=self._parse_quantity_settings(quantity_dict)) 

371 

372 def _get_scoring_filter_by_uuid(self, filter_uuid: str) -> str: 

373 """Finds scoring filter in the detect_config object by its uuid and returns its simulation name.""" 

374 for scoring_filter in self.detect_config.filters: 

375 if scoring_filter.uuid == filter_uuid: 

376 return scoring_filter.name 

377 

378 raise ValueError(f"No scoring filter with uuid {filter_uuid} in {self.detect_config.filters}.") 

379 

380 def _parse_geo_mat(self, json: dict) -> None: 

381 """Parses data from the input json into the geo_mat_config property""" 

382 self._parse_title(json) 

383 self._parse_materials(json) 

384 self._parse_figures(json) 

385 self._parse_zones(json) 

386 

387 def _parse_title(self, json: dict) -> None: 

388 """Parses data from the input json into the geo_mat_config property""" 

389 if "title" in json["project"] and len(json["project"]["title"]) > 0: 

390 self.geo_mat_config.title = json["project"]["title"] 

391 

392 def _parse_materials(self, json: dict) -> None: 

393 """Parse materials from JSON""" 

394 self.geo_mat_config.materials = [ 

395 Material(material["name"], material["sanitizedName"], material["uuid"], material["icru"]) 

396 for material in json["materialManager"].get("materials") 

397 ] 

398 

399 if json.get("physic") is not None and json["physic"].get("availableStoppingPowerFiles", False): 

400 for icru in json["physic"]["availableStoppingPowerFiles"]: 

401 value = json["physic"]["availableStoppingPowerFiles"][icru] 

402 self.geo_mat_config.available_custom_stopping_power_files[int(icru)] = StoppingPowerFile( 

403 int(icru), value.get("name", ''), value.get("content", '')) 

404 

405 def _parse_figures(self, json: dict) -> None: 

406 """Parse figures from JSON""" 

407 self.geo_mat_config.figures = [ 

408 solid_figures.parse_figure(figure_dict) for figure_dict in json["figureManager"].get('figures') 

409 ] 

410 

411 def _add_overridden_material(self, material: Material) -> None: 

412 """Parse materials from JSON""" 

413 self.geo_mat_config.materials.append(material) 

414 

415 def _get_material_by_uuid(self, material_uuid: str) -> Material: 

416 """Finds first material in the geo_mat_config object with corresponding uuid and returns it.""" 

417 for material in self.geo_mat_config.materials: 

418 if material.uuid == material_uuid: 

419 return material 

420 

421 raise ValueError(f"No material with uuid {material_uuid}.") 

422 

423 def _get_material_id(self, material_uuid: str) -> int: 

424 """Find material by uuid and return its id.""" 

425 offset = 0 

426 for idx, material in enumerate(self.geo_mat_config.materials): 

427 

428 # If the material is a DefaultMaterial then we need the value not its index, 

429 # the _value2member_map_ returns a map of values and members that allows us to check if 

430 # a given value is defined within the DefaultMaterial enum. 

431 if DefaultMaterial.is_default_material(material.icru): 

432 

433 if material.uuid == material_uuid: 

434 return int(material.icru) 

435 

436 # We need to count all DefaultMaterials prior to the searched one. 

437 offset += 1 

438 

439 elif material.uuid == material_uuid: 

440 # Only materials defined in mat.dat file are indexed. 

441 return idx + 1 - offset 

442 

443 raise ValueError(f"No material with uuid {material_uuid} in materials {self.geo_mat_config.materials}.") 

444 

445 def _parse_custom_material(self, json: dict) -> None: 

446 """Parse custom material from JSON and add it to the list of materials""" 

447 if ('customMaterial' not in json or json['customMaterial'] is None 

448 or 'materialPropertiesOverrides' not in json): 

449 return 

450 

451 icru = json['customMaterial']['icru'] 

452 available_files = self.geo_mat_config.available_custom_stopping_power_files 

453 is_stopping_power_file_available = icru in available_files 

454 custom_stopping_power = is_stopping_power_file_available and json['materialPropertiesOverrides'].get( 

455 'customStoppingPower', False) 

456 

457 overridden_material = Material(name=f"Custom {json['customMaterial']['name']}", 

458 sanitized_name=f"custom_{json['customMaterial']['sanitizedName']}", 

459 uuid=json['customMaterial']['uuid'], 

460 icru=json['customMaterial']['icru'], 

461 density=json['materialPropertiesOverrides'].get( 

462 'density', json['customMaterial']['density']), 

463 custom_stopping_power=custom_stopping_power) 

464 

465 self._add_overridden_material(overridden_material) 

466 

467 def _parse_zones(self, json: dict) -> None: 

468 """Parse zones from JSON""" 

469 self.geo_mat_config.zones = [] 

470 

471 for idx, zone in enumerate(json["zoneManager"]["zones"]): 

472 self._parse_custom_material(zone) 

473 self.geo_mat_config.zones.append( 

474 Zone( 

475 uuid=zone["uuid"], 

476 # lists are numbered from 0, but shieldhit zones are numbered from 1 

477 id=idx + 1, 

478 figures_operators=self._parse_csg_operations(zone["unionOperations"]), 

479 material=self._get_material_id(zone["materialUuid"]), 

480 material_override=zone.get('materialPropertiesOverrides', None), 

481 )) 

482 

483 if "worldZone" in json["zoneManager"]: 

484 self._parse_world_zone(json) 

485 

486 def _parse_world_zone(self, json: dict) -> None: 

487 """Parse the world zone and add it to the zone list""" 

488 # Add bounding figure to figures 

489 world_zone = json["zoneManager"]["worldZone"] 

490 world_figure = solid_figures.parse_figure(world_zone) 

491 self.geo_mat_config.figures.append(world_figure) 

492 

493 operations = self._calculate_world_zone_operations(len(self.geo_mat_config.figures)) 

494 material = self._get_material_id(world_zone["materialUuid"]) 

495 # add zone to zones for every operation in operations 

496 for operation in operations: 

497 self.geo_mat_config.zones.append( 

498 Zone( 

499 uuid='', 

500 id=len(self.geo_mat_config.zones) + 1, 

501 # world zone defined by bounding figure and contained zones 

502 figures_operators=[operation], 

503 # the material of the world zone is usually defined as vacuum 

504 material=material)) 

505 

506 # Adding Black Hole wrapper outside of the World Zone is redundant 

507 # if the World Zone already is made of Black Hole 

508 if material != DefaultMaterial.BLACK_HOLE.value: 

509 

510 # Add the figure that will serve as a black hole wrapper around the world zone 

511 black_hole_figure = solid_figures.parse_figure(world_zone) 

512 

513 # Make the figure slightly bigger. It will form the black hole wrapper around the simulation. 

514 black_hole_figure.expand(1.) 

515 

516 # Add the black hole figure to the figures list 

517 self.geo_mat_config.figures.append(black_hole_figure) 

518 

519 # Add the black hole wrapper zone to the zones list 

520 last_figure_idx = len(self.geo_mat_config.figures) 

521 self.geo_mat_config.zones.append( 

522 Zone( 

523 uuid="", 

524 id=len(self.geo_mat_config.zones) + 1, 

525 # slightly larger world zone - world zone 

526 figures_operators=[{last_figure_idx, -(last_figure_idx - 1)}], 

527 # the last material is the black hole 

528 material=DefaultMaterial.BLACK_HOLE)) 

529 

530 def _parse_csg_operations(self, operations: list[list[dict]]) -> list[set[int]]: 

531 """ 

532 Parse dict of csg operations to a list of sets. Sets contain a list of intersecting geometries. 

533 The list contains a union of geometries from sets. 

534 """ 

535 list_of_operations = [item for ops in operations for item in ops] 

536 parsed_operations = [] 

537 for operation in list_of_operations: 

538 # lists are numbered from 0, but SHIELD-HIT12A figures are numbered from 1 

539 figure_id = self._get_figure_index_by_uuid(operation["objectUuid"]) + 1 

540 if operation["mode"] == "union": 

541 parsed_operations.append({figure_id}) 

542 elif operation["mode"] == "subtraction": 

543 parsed_operations[-1].add(-figure_id) 

544 elif operation["mode"] == "intersection": 

545 parsed_operations[-1].add(figure_id) 

546 else: 

547 raise ValueError(f"Unexpected CSG operation: {operation['mode']}") 

548 

549 return parsed_operations 

550 

551 def _calculate_world_zone_operations(self, world_zone_figure: int) -> list[set[int]]: 

552 """Calculate the world zone operations. Take the world zone figure and subtract all geometries.""" 

553 # Sum all zones 

554 all_zones = [ 

555 figure_operators for zone in self.geo_mat_config.zones for figure_operators in zone.figures_operators 

556 ] 

557 

558 world_zone = [{world_zone_figure}] 

559 

560 for figure_set in all_zones: 

561 new_world_zone = [] 

562 for w_figure_set in world_zone: 

563 for figure in figure_set: 

564 new_world_zone.append({*w_figure_set, -figure}) 

565 world_zone = new_world_zone 

566 

567 # filter out sets containing opposite pairs of values 

568 world_zone = filter(lambda x: not any(abs(i) == abs(j) for i, j in itertools.combinations(x, 2)), world_zone) 

569 

570 return list(world_zone) 

571 

572 def _get_figure_index_by_uuid(self, figure_uuid: str) -> int: 

573 """Find the list index of a figure from geo_mat_config.figures by uuid. Useful when parsing CSG operations.""" 

574 for idx, figure in enumerate(self.geo_mat_config.figures): 

575 if figure.uuid == figure_uuid: 

576 return idx 

577 

578 raise ValueError(f"No figure with uuid \"{figure_uuid}\".") 

579 

580 def get_configs_json(self) -> dict: 

581 """Get JSON data for configs""" 

582 configs_json = super().get_configs_json() 

583 configs_json.update({ 

584 "beam.dat": str(self.beam_config), 

585 "mat.dat": self.geo_mat_config.get_mat_string(), 

586 "detect.dat": str(self.detect_config), 

587 "geo.dat": self.geo_mat_config.get_geo_string() 

588 }) 

589 

590 files = {} 

591 for icru in self.geo_mat_config.available_custom_stopping_power_files: 

592 file = self.geo_mat_config.available_custom_stopping_power_files[icru] 

593 files[file.name] = file.content 

594 

595 configs_json.update(files) 

596 

597 if self.beam_config.beam_source_type == BeamSourceType.FILE: 

598 filename_of_beam_source_file: str = 'sobp.dat' 

599 if not self.beam_config.beam_source_filename: 

600 filename_of_beam_source_file = str(self.beam_config.beam_source_filename) 

601 configs_json[filename_of_beam_source_file] = str(self.beam_config.beam_source_file_content) 

602 

603 if self.beam_config.modulator is not None: 

604 filename_of_modulator_source_file: str = self.beam_config.modulator.filename 

605 configs_json[filename_of_modulator_source_file] = str(self.beam_config.modulator.file_content) 

606 

607 return configs_json