simind-python-connector 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. simind_python_connector/__init__.py +72 -0
  2. simind_python_connector/backends/__init__.py +480 -0
  3. simind_python_connector/backends/base.py +387 -0
  4. simind_python_connector/backends/sirf_backend.py +309 -0
  5. simind_python_connector/backends/stir_backend.py +395 -0
  6. simind_python_connector/builders/__init__.py +19 -0
  7. simind_python_connector/builders/acquisition_builder.py +526 -0
  8. simind_python_connector/builders/image_builder.py +217 -0
  9. simind_python_connector/configs/AnyScan.yaml +420 -0
  10. simind_python_connector/configs/Discovery670.yaml +412 -0
  11. simind_python_connector/configs/Example.yaml +420 -0
  12. simind_python_connector/configs/MLD001_SCAN0.yaml +426 -0
  13. simind_python_connector/configs/__init__.py +41 -0
  14. simind_python_connector/configs/input.smc +51 -0
  15. simind_python_connector/connectors/__init__.py +24 -0
  16. simind_python_connector/connectors/_spacing.py +69 -0
  17. simind_python_connector/connectors/base.py +40 -0
  18. simind_python_connector/connectors/python_connector.py +355 -0
  19. simind_python_connector/connectors/pytomography_adaptor.py +263 -0
  20. simind_python_connector/connectors/sirf_adaptor.py +164 -0
  21. simind_python_connector/connectors/stir_adaptor.py +164 -0
  22. simind_python_connector/converters/__init__.py +16 -0
  23. simind_python_connector/converters/attenuation.py +367 -0
  24. simind_python_connector/converters/dicom_to_stir.py +3 -0
  25. simind_python_connector/converters/simind_to_stir.py +769 -0
  26. simind_python_connector/core/__init__.py +7 -0
  27. simind_python_connector/core/config.py +939 -0
  28. simind_python_connector/core/executor.py +96 -0
  29. simind_python_connector/core/types.py +203 -0
  30. simind_python_connector/data/Schneider2000.json +222 -0
  31. simind_python_connector/data/__init__.py +25 -0
  32. simind_python_connector/data/bone.atn +187 -0
  33. simind_python_connector/data/h2o.atn +92 -0
  34. simind_python_connector/utils/__init__.py +120 -0
  35. simind_python_connector/utils/backend_access.py +121 -0
  36. simind_python_connector/utils/import_helpers.py +74 -0
  37. simind_python_connector/utils/interfile_numpy.py +195 -0
  38. simind_python_connector/utils/interfile_parser.py +175 -0
  39. simind_python_connector/utils/io_utils.py +14 -0
  40. simind_python_connector/utils/simind_utils.py +70 -0
  41. simind_python_connector/utils/sirf_stir_utils.py +194 -0
  42. simind_python_connector/utils/stir_utils.py +485 -0
  43. simind_python_connector-1.0.0.dist-info/METADATA +274 -0
  44. simind_python_connector-1.0.0.dist-info/RECORD +47 -0
  45. simind_python_connector-1.0.0.dist-info/WHEEL +5 -0
  46. simind_python_connector-1.0.0.dist-info/licenses/LICENSE +195 -0
  47. simind_python_connector-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,939 @@
1
+ # This file contains a wrapper to access, edit and save simulation configuration
2
+ # files for the Simind Monte Carlo simulation software.
3
+ # It can work as a standalone to make the .smc files accessible and editable in
4
+ # a more user-friendly way.
5
+ # Or you can use it with the Simulator class to run the simulation with SIRF
6
+ # in python.
7
+
8
+ ### Author: Sam Porter
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+
15
+
16
+ class SimulationConfig:
17
+ """
18
+ SimulationConfig Class with YAML import/export capabilities
19
+
20
+ This class is designed to parse, manipulate, and save simulation configuration
21
+ files. It provides easy access
22
+ to configuration parameters, including index-based data, simulation flags,
23
+ text variables, and associated data files.
24
+ Now includes YAML export/import for better visualization and editing.
25
+
26
+ Attributes:
27
+ filepath (str): Path to the simulation configuration file.
28
+ index_dict (dict): Dictionary mapping indices to parameter names for basic
29
+ change data.
30
+ flag_dict (dict): Dictionary mapping indices to simulation flags.
31
+ data_file_dict (dict): Dictionary mapping indices to data file descriptions.
32
+ data (list): List of basic change data values.
33
+ flags (str): String representing simulation flags as 'T' (True) or 'F' (False).
34
+ text_variables (dict): Dictionary of text variables.
35
+ data_files (dict): Dictionary of associated data files.
36
+ comment (str): Comment section from the configuration file.
37
+ """
38
+
39
+ def __init__(self, filepath):
40
+ """
41
+ Initialize the SimulationConfig instance.
42
+
43
+ Args:
44
+ filepath (str): Path to the simulation configuration file.
45
+ """
46
+ self.filepath = filepath
47
+ self.index_dict = {
48
+ 1: "photon_energy",
49
+ 2: "source_half_length",
50
+ 3: "source_half_width",
51
+ 4: "source_half_height",
52
+ 5: "phantom_half_length",
53
+ 6: "phantom_half_width",
54
+ 7: "phantom_half_height",
55
+ 8: "crystal_half_length_radius",
56
+ 9: "crystal_thickness",
57
+ 10: "crystal_half_width",
58
+ 11: "backscattering_material_thickness",
59
+ 12: "height_to_detector_surface",
60
+ 13: "cover_thickness",
61
+ 14: "phantom_type",
62
+ 15: "source_type",
63
+ 16: "shift_source_x",
64
+ 17: "shift_source_y",
65
+ 18: "shift_source_z",
66
+ 19: "photon_direction",
67
+ 20: "upper_window_threshold",
68
+ 21: "lower_window_threshold",
69
+ 22: "energy_resolution",
70
+ 23: "intrinsic_resolution",
71
+ 24: "emitted_photons_per_decay",
72
+ 25: "source_activity",
73
+ 26: "number_photon_histories",
74
+ 27: "kev_per_channel",
75
+ 28: "pixel_size_simulated_image",
76
+ 29: "spect_no_projections",
77
+ 30: "spect_rotation",
78
+ 31: "pixel_size_density_images",
79
+ 32: "orientation_density_images",
80
+ 33: "first_image_density_images",
81
+ 34: "number_density_images",
82
+ 35: "density_limit_border",
83
+ 36: "shift_density_images_y",
84
+ 37: "shift_density_images_z",
85
+ 38: "step_size_photon_path_simulation",
86
+ 39: "shift_density_images_x",
87
+ 40: "density_threshold_soft_bone",
88
+ 41: "spect_starting_angle",
89
+ 42: "spect_orbital_rotation_fraction",
90
+ 43: "camera_offset_x",
91
+ 44: "camera_offset_y",
92
+ 45: "code_definitions_zubal_phantom",
93
+ 46: "hole_size_x",
94
+ 47: "hole_size_y",
95
+ 48: "distance_between_holes_x",
96
+ 49: "distance_between_holes_y",
97
+ 50: "shift_center_hole_x",
98
+ 51: "shift_center_hole_y",
99
+ 52: "collimator_thickness",
100
+ 53: "collimator_routine",
101
+ 54: "hole_shape",
102
+ 55: "type",
103
+ 56: "distance_collimator_detector",
104
+ 57: "unused_parameter_1",
105
+ 58: "unused_parameter_2",
106
+ 59: "random_collimator_movement",
107
+ 60: "unused_parameter_3",
108
+ 76: "matrix_size_image_i",
109
+ 77: "matrix_size_image_j",
110
+ 78: "matrix_size_density_map_i",
111
+ 79: "matrix_size_source_map_i",
112
+ 80: "energy_spectra_channels",
113
+ 81: "matrix_size_density_map_j",
114
+ 82: "matrix_size_source_map_j",
115
+ 83: "cutoff_energy_terminate_photon_history",
116
+ 84: "scoring_routine",
117
+ 85: "csv_file_content",
118
+ 91: "voltage",
119
+ 92: "mobility_life_electrons",
120
+ 93: "mobility_life_holes",
121
+ 94: "contact_pad_size",
122
+ 95: "anode_element_pitch",
123
+ 96: "exponential_decay_constant_tau",
124
+ 97: "components_hecht_formula",
125
+ 98: "energy_resolution_model",
126
+ 99: "cloud_mobility",
127
+ 100: "detector_array_size_i",
128
+ 101: "detector_array_size_j",
129
+ }
130
+ self.flag_dict = {
131
+ 1: "write_results_to_screen",
132
+ 2: "write_images_to_files",
133
+ 3: "write_pulse_height_distribution_to_file",
134
+ 4: "include_collimator",
135
+ 5: "simulate_spect_study",
136
+ 6: "include_characteristic_xray_emissions",
137
+ 7: "include_backscattering_material",
138
+ 8: "use_random_seed_value",
139
+ 9: "currently_not_in_use",
140
+ 10: "include_interactions_in_cover",
141
+ 11: "include_interactions_in_phantom",
142
+ 12: "include_energy_resolution_in_crystal",
143
+ 13: "include_forced_interactions_in_crystal",
144
+ 14: "write_interfile_header_files",
145
+ 15: "save_aligned_phantom_images",
146
+ }
147
+ self.data_file_dict = {
148
+ 1: "phantom_soft_tissue",
149
+ 2: "phantom_bone",
150
+ 3: "cover_material",
151
+ 4: "crystal_material",
152
+ 5: "image_file_phantom",
153
+ 6: "image_file_source",
154
+ 7: "backscatter_material",
155
+ 8: "energy_resolution_file",
156
+ 9: "unknown_file_1",
157
+ 10: "unknown_file_2",
158
+ 11: "unknown_file_3",
159
+ 12: "unknown_file_4",
160
+ }
161
+
162
+ # Create organized parameter groups for better YAML structure
163
+ self.parameter_groups = {
164
+ "source": [1, 2, 3, 4, 15, 16, 17, 18, 19, 24, 25, 26, 79, 82],
165
+ "phantom": [5, 6, 7, 14, 31, 32, 33, 34, 35, 36, 37, 39, 40, 45, 78, 81],
166
+ "detector_crystal": [
167
+ 8,
168
+ 9,
169
+ 10,
170
+ 12,
171
+ 13,
172
+ 22,
173
+ 23,
174
+ 91,
175
+ 92,
176
+ 93,
177
+ 94,
178
+ 95,
179
+ 96,
180
+ 97,
181
+ 98,
182
+ 99,
183
+ 100,
184
+ 101,
185
+ ],
186
+ "collimator": [46, 47, 48, 49, 50, 51, 52, 53, 54, 56, 59],
187
+ "energy_analysis": [20, 21, 27, 80, 83],
188
+ "spect_imaging": [28, 29, 30, 41, 42, 43, 44, 76, 77],
189
+ "simulation_control": [38, 55, 84, 85],
190
+ "unused_parameters": [57, 58, 60],
191
+ }
192
+
193
+ self.data = None
194
+ self.flags = None
195
+ self.text_variables = {}
196
+ self.data_files = {}
197
+ self.comment = None
198
+
199
+ print(filepath)
200
+
201
+ # Detect file type and load accordingly
202
+ if str(filepath).endswith(".yaml") or str(filepath).endswith(".yml"):
203
+ # Initialize with default values first
204
+ self._initialise_yaml_defaults()
205
+ self.import_yaml(filepath)
206
+ else:
207
+ # Assume .smc format
208
+ self.import_smc(filepath)
209
+
210
+ def _initialise_yaml_defaults(self):
211
+ """Initialize with default values for YAML loading."""
212
+ # Set up defaults for when loading from YAML
213
+ self.data = [0.0] * 101 # Initialize with 101 zeros
214
+ self.flags = "F" * 15 # Initialize with 15 False flags
215
+ self.text_variables = {i: "none" for i in range(1, 13)}
216
+ self.data_files = {}
217
+ self.comment = "Loaded from YAML"
218
+
219
+ def _initialise_sms_defaults(self):
220
+ """Initialize with default values for SMC loading."""
221
+ self.comment = "Loaded from SMC"
222
+
223
+ def import_smc(self, filepath):
224
+ """
225
+ Parse the simulation configuration file and populate attributes.
226
+ """
227
+ with open(filepath, "r") as file:
228
+ lines = file.readlines()
229
+ self.comment = lines[1].strip()
230
+
231
+ # Parsing Basic Change data
232
+ data_lines = lines[3:27]
233
+ data_string = " ".join(data_lines).replace("\n", "")
234
+ self.data = [
235
+ float(val) for val in re.findall(r"-?\d+\.\d+E[+-]\d+", data_string)
236
+ ]
237
+
238
+ # Parsing Simulation flags
239
+ self.flags = lines[28].strip().replace(" ", "")
240
+
241
+ # Parsing Text Variables
242
+ text_variables_start = 29
243
+ text_variables_count = int(lines[text_variables_start].split()[0])
244
+ text_variables_lines = lines[
245
+ text_variables_start + 1 : text_variables_start
246
+ + 1
247
+ + text_variables_count
248
+ ]
249
+ self.text_variables = {
250
+ i + 1: text_variables_lines[i].strip()
251
+ for i in range(text_variables_count)
252
+ }
253
+
254
+ # Parsing Data files
255
+ data_files_start = 38
256
+ data_files_count = int(lines[data_files_start].split()[0])
257
+ data_files_lines = lines[
258
+ data_files_start + 1 : data_files_start + 1 + data_files_count
259
+ ]
260
+ self.data_files = {
261
+ i + 1: data_files_lines[i].strip()
262
+ for i in range(data_files_count) # Start from 1, not 7
263
+ }
264
+
265
+ def to_yaml_dict(self):
266
+ """
267
+ Convert the configuration to a structured dictionary suitable for YAML export.
268
+
269
+ Returns:
270
+ dict: Organized configuration data
271
+ """
272
+ yaml_dict = {
273
+ "metadata": {"comment": self.comment, "source_file": str(self.filepath)},
274
+ "parameters": {},
275
+ "simulation_flags": {},
276
+ "text_variables": dict(self.text_variables),
277
+ "data_files": {},
278
+ }
279
+
280
+ # Organize parameters by groups
281
+ for group_name, indices in self.parameter_groups.items():
282
+ yaml_dict["parameters"][group_name] = {}
283
+ for idx in indices:
284
+ if idx in self.index_dict and idx <= len(self.data):
285
+ param_name = self.index_dict[idx]
286
+ value = self.data[idx - 1]
287
+ yaml_dict["parameters"][group_name][param_name] = {
288
+ "index": idx,
289
+ "value": float(value),
290
+ "description": self._get_parameter_description(param_name),
291
+ }
292
+
293
+ # Add simulation flags
294
+ for idx, flag_name in self.flag_dict.items():
295
+ if idx <= len(self.flags):
296
+ yaml_dict["simulation_flags"][flag_name] = {
297
+ "index": idx,
298
+ "enabled": self.flags[idx - 1] == "T",
299
+ }
300
+
301
+ # Add data files with descriptions
302
+ for idx, file_desc in self.data_file_dict.items():
303
+ if idx in self.data_files:
304
+ yaml_dict["data_files"][file_desc] = {
305
+ "index": idx,
306
+ "filepath": self.data_files[idx],
307
+ }
308
+
309
+ return yaml_dict
310
+
311
+ def _get_parameter_description(self, param_name):
312
+ """
313
+ Get a human-readable description for parameters.
314
+
315
+ WARNING: Parameter descriptions are based on research of SIMIND documentation
316
+ and may vary between SIMIND versions. SIMIND 7.0+ introduced significant
317
+ parameter reorganization. Always verify against your specific SIMIND version's
318
+ official manual for accurate parameter definitions and valid ranges.
319
+
320
+ For definitive parameter specifications, consult:
321
+ - Official SIMIND manual at simind.blogg.lu.se
322
+ - "The SIMIND Monte Carlo Program" chapter in Monte Carlo Calculations
323
+ in Nuclear Medicine (CRC Press, 2012)
324
+ """
325
+ descriptions = {
326
+ # Source parameters
327
+ "photon_energy": (
328
+ "Photon energy in keV (e.g., 140 for 99mTc, 208 for 177Lu)"
329
+ ),
330
+ "source_half_length": "Source half-length in cm",
331
+ "source_half_width": "Source half-width in cm",
332
+ "source_half_height": "Source half-height in cm",
333
+ "source_type": (
334
+ "Source type code (0=sphere, 1=cylinder, etc.) - check SIMIND manual"
335
+ ),
336
+ "shift_source_x": "Shift of source in x-direction (cm)",
337
+ "shift_source_y": "Shift of source in y-direction (cm)",
338
+ "shift_source_z": "Shift of source in z-direction (cm)",
339
+ "photon_direction": "Photon direction code (2=isotropic typical)",
340
+ "emitted_photons_per_decay": "Number of photons emitted per decay",
341
+ "source_activity": "Source activity in MBq",
342
+ "number_photon_histories": (
343
+ "Number of photon histories to simulate (10^6 typical minimum)"
344
+ ),
345
+ "matrix_size_source_map_i": (
346
+ "Matrix size for source map (i-direction) - 128x128 standard"
347
+ ),
348
+ "matrix_size_source_map_j": (
349
+ "Matrix size for source map (j-direction) - 128x128 standard"
350
+ ),
351
+ # Phantom parameters
352
+ "phantom_half_length": "Phantom half-length in cm",
353
+ "phantom_half_width": "Phantom half-width in cm",
354
+ "phantom_half_height": "Phantom half-height in cm",
355
+ "phantom_type": "Phantom type code",
356
+ "pixel_size_density_images": "Pixel size for density images (cm)",
357
+ "orientation_density_images": "Orientation of density images",
358
+ "first_image_density_images": "First image number for density images",
359
+ "number_density_images": "Number of density images",
360
+ "density_limit_border": "Density limit at border",
361
+ "shift_density_images_x": "Shift of density images in x-direction (cm)",
362
+ "shift_density_images_y": "Shift of density images in y-direction (cm)",
363
+ "shift_density_images_z": "Shift of density images in z-direction (cm)",
364
+ "density_threshold_soft_bone": "Density threshold for soft bone",
365
+ "code_definitions_zubal_phantom": "Code definitions for Zubal phantom",
366
+ "matrix_size_density_map_i": "Matrix size for density map (i-direction)",
367
+ "matrix_size_density_map_j": "Matrix size for density map (j-direction)",
368
+ # Detector/Crystal parameters
369
+ "crystal_half_length_radius": (
370
+ "Crystal half-length/radius in cm (circular detectors use radius)"
371
+ ),
372
+ "crystal_thickness": ("Crystal thickness in cm (NaI(Tl) typical: 0.95cm)"),
373
+ "crystal_half_width": (
374
+ "Crystal half-width in cm (for rectangular crystals)"
375
+ ),
376
+ "height_to_detector_surface": (
377
+ "Height from collimator to detector surface (cm)"
378
+ ),
379
+ "cover_thickness": ("Cover thickness in cm (typically Al or Be window)"),
380
+ "energy_resolution": (
381
+ "Energy resolution FWHM (%) at reference energy "
382
+ "(9-12% typical at 140keV)"
383
+ ),
384
+ "intrinsic_resolution": (
385
+ "Intrinsic spatial resolution FWHM (cm) (3-4mm typical)"
386
+ ),
387
+ "voltage": "Applied voltage (V) for semiconductor detectors",
388
+ "mobility_life_electrons": (
389
+ "Mobility-life product for electrons (semiconductor detectors)"
390
+ ),
391
+ "mobility_life_holes": (
392
+ "Mobility-life product for holes (semiconductor detectors)"
393
+ ),
394
+ "contact_pad_size": ("Contact pad size (cm) for pixelated detectors"),
395
+ "anode_element_pitch": ("Anode element pitch (cm) for pixelated detectors"),
396
+ "exponential_decay_constant_tau": (
397
+ "Exponential decay constant tau for charge collection"
398
+ ),
399
+ "components_hecht_formula": (
400
+ "Components for Hecht formula (charge collection efficiency)"
401
+ ),
402
+ "energy_resolution_model": (
403
+ "Energy resolution model code (check SIMIND manual for options)"
404
+ ),
405
+ "cloud_mobility": "Cloud mobility parameter for charge collection",
406
+ "detector_array_size_i": (
407
+ "Detector array size (i-direction) for pixelated systems"
408
+ ),
409
+ "detector_array_size_j": (
410
+ "Detector array size (j-direction) for pixelated systems"
411
+ ),
412
+ # Collimator parameters
413
+ "hole_size_x": (
414
+ "Collimator hole diameter (cm) - LEHR: 0.111cm, HEGP: 0.24cm"
415
+ ),
416
+ "hole_size_y": (
417
+ "Collimator hole diameter (cm) - should match hole_size_x for "
418
+ "round holes"
419
+ ),
420
+ "distance_between_holes_x": (
421
+ "Distance between hole centers (cm) - LEHR: 0.16cm septal thickness"
422
+ ),
423
+ "distance_between_holes_y": (
424
+ "Distance between hole centers (cm) - hexagonal pattern spacing"
425
+ ),
426
+ "shift_center_hole_x": (
427
+ "Shift of center hole in x-direction (cm) for alignment"
428
+ ),
429
+ "shift_center_hole_y": (
430
+ "Shift of center hole in y-direction (cm) for alignment"
431
+ ),
432
+ "collimator_thickness": (
433
+ "Collimator thickness (cm) - LEHR: 2.405cm, HEGP: 5.9cm"
434
+ ),
435
+ "collimator_routine": (
436
+ "Collimator routine code (0=no collimator, 1=parallel holes, etc.)"
437
+ ),
438
+ "hole_shape": "Hole shape code (0=round, 1=square, 2=hexagonal)",
439
+ "distance_collimator_detector": (
440
+ "Distance from collimator face to detector surface (cm)"
441
+ ),
442
+ "random_collimator_movement": (
443
+ "Random collimator movement parameter (for manufacturing variations)"
444
+ ),
445
+ # Energy analysis parameters
446
+ "upper_window_threshold": (
447
+ "Upper energy window threshold (keV) - set to -100 for automatic"
448
+ ),
449
+ "lower_window_threshold": (
450
+ "Lower energy window threshold (keV) - set to -100 for automatic"
451
+ ),
452
+ "kev_per_channel": "keV per channel for energy spectrum binning",
453
+ "energy_spectra_channels": (
454
+ "Number of energy spectra channels (512 typical)"
455
+ ),
456
+ "cutoff_energy_terminate_photon_history": (
457
+ "Cutoff energy to terminate photon history (keV)"
458
+ ),
459
+ # SPECT imaging parameters
460
+ "pixel_size_simulated_image": (
461
+ "Pixel size for simulated images (cm) - affects resolution vs FOV"
462
+ ),
463
+ "spect_no_projections": (
464
+ "Number of SPECT projections (64, 120, 128 typical)"
465
+ ),
466
+ "spect_rotation": "SPECT rotation parameter (2=360° typical)",
467
+ "spect_starting_angle": ("SPECT starting angle (degrees) - 0° = anterior"),
468
+ "spect_orbital_rotation_fraction": (
469
+ "SPECT orbital rotation fraction (1.0 = full orbit)"
470
+ ),
471
+ "camera_offset_x": (
472
+ "Camera offset in x-direction (cm) from rotation center"
473
+ ),
474
+ "camera_offset_y": (
475
+ "Camera offset in y-direction (cm) from rotation center"
476
+ ),
477
+ "matrix_size_image_i": (
478
+ "Matrix size for images (i-direction) - 128x128 standard"
479
+ ),
480
+ "matrix_size_image_j": (
481
+ "Matrix size for images (j-direction) - 128x128 standard"
482
+ ),
483
+ # Simulation control parameters
484
+ "step_size_photon_path_simulation": (
485
+ "Step size for photon path simulation (cm) - smaller = more accurate"
486
+ ),
487
+ "type": "General type parameter - check SIMIND manual for current meaning",
488
+ "scoring_routine": "Scoring routine code - affects output data collection",
489
+ "csv_file_content": "CSV file content parameter - for custom data output",
490
+ "backscattering_material_thickness": (
491
+ "Backscattering material thickness (cm)"
492
+ ),
493
+ # Unused parameters - NOTE: May be used in newer SIMIND versions
494
+ "unused_parameter_1": "Unused parameter 1 - reserved for future use",
495
+ "unused_parameter_2": "Unused parameter 2 - reserved for future use",
496
+ "unused_parameter_3": "Unused parameter 3 - reserved for future use",
497
+ }
498
+ return descriptions.get(param_name, "No description available")
499
+
500
+ def export_yaml(self, filepath):
501
+ """
502
+ Export the configuration to a YAML file.
503
+
504
+ Args:
505
+ filepath (str): Path for the output YAML file
506
+ """
507
+ yaml_dict = self.to_yaml_dict()
508
+
509
+ filepath = Path(filepath)
510
+ if filepath.suffix != ".yaml":
511
+ filepath = filepath.with_suffix(".yaml")
512
+
513
+ with open(filepath, "w") as file:
514
+ yaml.dump(
515
+ yaml_dict, file, default_flow_style=False, indent=2, sort_keys=False
516
+ )
517
+
518
+ print(f"Configuration exported to {filepath}")
519
+
520
+ def import_yaml(self, filepath):
521
+ """
522
+ Import configuration from a YAML file.
523
+
524
+ Args:
525
+ filepath (str): Path to the input YAML file
526
+ """
527
+ with open(filepath, "r") as file:
528
+ yaml_dict = yaml.safe_load(file)
529
+
530
+ # Update comment
531
+ if "metadata" in yaml_dict and "comment" in yaml_dict["metadata"]:
532
+ self.comment = yaml_dict["metadata"]["comment"]
533
+
534
+ # Update parameters
535
+ if "parameters" in yaml_dict:
536
+ for group_name, group_params in yaml_dict["parameters"].items():
537
+ for param_name, param_data in group_params.items():
538
+ if "index" in param_data and "value" in param_data:
539
+ idx = param_data["index"]
540
+ value = param_data["value"]
541
+ if idx in self.index_dict and idx <= len(self.data):
542
+ self.data[idx - 1] = float(value)
543
+
544
+ # Update flags
545
+ if "simulation_flags" in yaml_dict:
546
+ flags = list(self.flags)
547
+ for flag_name, flag_data in yaml_dict["simulation_flags"].items():
548
+ if "index" in flag_data and "enabled" in flag_data:
549
+ idx = flag_data["index"]
550
+ enabled = flag_data["enabled"]
551
+ if idx in self.flag_dict and idx <= len(flags):
552
+ flags[idx - 1] = "T" if enabled else "F"
553
+ self.flags = "".join(flags)
554
+
555
+ # Update data files
556
+ if "data_files" in yaml_dict:
557
+ for file_desc, file_data in yaml_dict["data_files"].items():
558
+ if "index" in file_data and "filepath" in file_data:
559
+ idx = file_data["index"]
560
+ f = file_data["filepath"]
561
+ if idx in self.data_file_dict:
562
+ self.data_files[idx] = f
563
+
564
+ # Update text variables
565
+ if "text_variables" in yaml_dict:
566
+ self.text_variables = yaml_dict["text_variables"]
567
+
568
+ def validate_parameters(self):
569
+ """
570
+ Basic parameter validation based on typical SIMIND ranges.
571
+
572
+ NOTE: This provides basic sanity checks only. Consult official SIMIND
573
+ documentation for complete parameter validation rules and constraints.
574
+ """
575
+ warnings = []
576
+
577
+ # Energy validation
578
+ if (
579
+ self.get_value("photon_energy") < 10
580
+ or self.get_value("photon_energy") > 500
581
+ ):
582
+ warnings.append("Photon energy outside typical range (10-500 keV)")
583
+
584
+ # Matrix size validation
585
+ matrix_i = self.get_value("matrix_size_image_i")
586
+ matrix_j = self.get_value("matrix_size_image_j")
587
+ if matrix_i != matrix_j:
588
+ warnings.append(
589
+ "Non-square matrix sizes may cause issues in some SIMIND versions"
590
+ )
591
+
592
+ # Energy window validation
593
+ upper_window = self.get_value("upper_window_threshold")
594
+ lower_window = self.get_value("lower_window_threshold")
595
+ if upper_window > 0 and lower_window > 0 and lower_window >= upper_window:
596
+ warnings.append("Lower energy window >= upper energy window")
597
+
598
+ # Collimator validation
599
+ if self.get_flag("include_collimator"):
600
+ if self.get_value("collimator_thickness") <= 0:
601
+ warnings.append("Collimator enabled but thickness <= 0")
602
+
603
+ # Crystal validation
604
+ if self.get_value("crystal_thickness") <= 0:
605
+ warnings.append("Crystal thickness <= 0")
606
+
607
+ if warnings:
608
+ print("Parameter validation warnings:")
609
+ for warning in warnings:
610
+ print(f" - {warning}")
611
+ else:
612
+ print("Basic parameter validation passed")
613
+
614
+ return len(warnings) == 0
615
+
616
+ def get_simind_version_info(self):
617
+ """
618
+ Extract version information from comment or suggest manual verification.
619
+ """
620
+ print("SIMIND Version Detection:")
621
+ print(f"Comment field: '{self.comment}'")
622
+ print("\nIMPORTANT: Parameter meanings may vary between SIMIND versions.")
623
+ print("Version 7.0+ introduced significant parameter reorganization.")
624
+ print("Always verify parameters against your specific SIMIND version's manual.")
625
+ print("Official documentation: https://simind.blogg.lu.se/")
626
+
627
+ def print_config(self):
628
+ """
629
+ Print the configuration details, including comments, basic change data,
630
+ flags, text variables, and data files.
631
+ """
632
+ print(f"Comment: {self.comment}")
633
+ print("Basic Change data:")
634
+ for key, val in self.index_dict.items():
635
+ print(f"index {key}: {val}: {self.data[key - 1]}")
636
+ print("Simulation flags:")
637
+ for key, val in self.flag_dict.items():
638
+ print(f"flag {key}: {val}: {self.flags[key - 1]}")
639
+ print("Text Variables:")
640
+ for key, val in self.text_variables.items():
641
+ print(f"{key}: {val}")
642
+ print("Data Files:")
643
+ for key, val in self.data_files.items():
644
+ print(f"{key}: {val}")
645
+
646
+ def get_value(self, index):
647
+ """
648
+ Get the value of a parameter by its index or description.
649
+
650
+ Args:
651
+ index (int or str): Parameter index or description.
652
+
653
+ Returns:
654
+ float: Parameter value.
655
+ """
656
+ if isinstance(index, int) and index in self.index_dict:
657
+ return self.data[index - 1]
658
+ elif isinstance(index, str) and index in self.index_dict.values():
659
+ for key, val in self.index_dict.items():
660
+ if val == index:
661
+ return self.data[key - 1]
662
+ else:
663
+ raise ValueError("index must be a valid integer or string")
664
+
665
+ def set_value(self, index, value):
666
+ """
667
+ Set the value of a parameter by its index or description.
668
+
669
+ Args:
670
+ index (int or str): Parameter index or description.
671
+ value (float): New value for the parameter.
672
+ """
673
+ if isinstance(index, int) and index in self.index_dict:
674
+ self.data[index - 1] = value
675
+ elif isinstance(index, str) and index in self.index_dict.values():
676
+ for key, val in self.index_dict.items():
677
+ if val == index:
678
+ self.data[key - 1] = value
679
+ else:
680
+ raise ValueError("index must be an integer or string")
681
+
682
+ def get_flag(self, index):
683
+ """
684
+ Get the value of a simulation flag by its index or description.
685
+
686
+ Args:
687
+ index (int or str): Flag index or description.
688
+
689
+ Returns:
690
+ bool: True if the flag is set, False otherwise.
691
+ """
692
+ if isinstance(index, int) and index in self.flag_dict:
693
+ return self.flags[index - 1] == "T"
694
+ elif isinstance(index, str) and index in self.flag_dict.values():
695
+ for key, val in self.flag_dict.items():
696
+ if val == index:
697
+ return self.flags[key - 1] == "T"
698
+ else:
699
+ raise ValueError("index must be a valid integer or string")
700
+
701
+ def set_flag(self, index, value):
702
+ """
703
+ Set the value of a simulation flag by its index or description.
704
+
705
+ Args:
706
+ index (int or str): Flag index or description.
707
+ value (bool): True to set the flag, False to clear it.
708
+ """
709
+ if isinstance(index, int) and index in self.flag_dict:
710
+ flags = list(self.flags)
711
+ flags[index - 1] = "T" if value else "F"
712
+ self.flags = "".join(flags)
713
+ elif isinstance(index, str) and index in self.flag_dict.values():
714
+ for key, val in self.flag_dict.items():
715
+ if val == index:
716
+ flags = list(self.flags)
717
+ flags[key - 1] = "T" if value else "F"
718
+ self.flags = "".join(flags)
719
+ else:
720
+ raise ValueError("index must be an integer or string")
721
+
722
+ def set_data_file(self, index, filepath):
723
+ """
724
+ Set the path to a data file by its index.
725
+
726
+ Args:
727
+ index (int or str): Data file index or description.
728
+ filepath (str): Path to the data file.
729
+ """
730
+ if isinstance(index, int) and index in self.data_file_dict:
731
+ if index in self.data_files:
732
+ self.data_files[index] = filepath
733
+ elif isinstance(index, str) and index in self.data_file_dict.values():
734
+ for key, val in self.data_files.items():
735
+ if val == index:
736
+ self.data_files[key] = filepath
737
+ else:
738
+ raise ValueError("index must be an integer or string")
739
+
740
+ def get_data_file(self, index):
741
+ """
742
+ Get the path to a data file by its index or description.
743
+
744
+ Args:
745
+ index (int or str): Data file index or description.
746
+
747
+ Returns:
748
+ str: Path to the data file.
749
+ """
750
+ if isinstance(index, int) and index in self.data_file_dict:
751
+ return self.data_files[index]
752
+ elif isinstance(index, str) and index in self.data_file_dict.values():
753
+ for key, val in self.data_files.items():
754
+ if val == index:
755
+ return key
756
+ else:
757
+ raise ValueError("index must be an integer or string")
758
+
759
+ def get_comment(self):
760
+ return self.comment
761
+
762
+ def set_comment(self, comment):
763
+ self.comment = comment
764
+
765
+ def save_file(self, filepath):
766
+ # Ensure filepath is a Path object
767
+ filepath = Path(filepath)
768
+
769
+ # Check if the file has the correct suffix, add it if missing
770
+ if filepath.suffix != ".smc":
771
+ filepath = filepath.with_suffix(".smc")
772
+
773
+ with open(filepath, "w") as file:
774
+ comment = self.comment + " " * (70 - len(self.comment))
775
+ file.write(f"SMCV2\n{comment}\n")
776
+ file.write(" 120 # Basic Change data\n")
777
+
778
+ for i in range(0, 120, 5): # Force exactly 120 values
779
+ line = ""
780
+ for j in range(5):
781
+ if i + j < len(self.data):
782
+ val = self.data[i + j]
783
+ else:
784
+ val = 0.0 # Pad with zeros if needed
785
+
786
+ # Format the value in scientific notation with 5 decimal places
787
+ formatted_val = f"{val:.5E}"
788
+ if val != 0:
789
+ # Split the formatted value into its components: sign,
790
+ # digit, and exponent
791
+ sign = "-" if val < 0 else " "
792
+ parts = formatted_val.split("E")
793
+ digits = parts[0].replace("-", "")
794
+ # remove final 0
795
+ digits = digits[:-1]
796
+ exponent = int(parts[1])
797
+
798
+ # Ensure it starts with '0' after the sign
799
+ if "." in digits:
800
+ digits = digits.replace(".", "")
801
+
802
+ # Since we've moved the decimal place one position to the
803
+ # right, increment the exponent
804
+ new_exponent = exponent + 1
805
+
806
+ # Reconstruct the formatted value
807
+ formatted_val = f"{sign}0.{digits}E{new_exponent:+03d}"
808
+ else:
809
+ # If the value is 0, we don't need to format it
810
+ formatted_val = f" {val:.5E}"
811
+
812
+ # Add the formatted value to the line
813
+ line += f"{formatted_val}"
814
+
815
+ # Write the formatted line to the file
816
+ file.write(f"{line}\n")
817
+
818
+ file.write(f" 30 # Simulation flags\n{self.flags}\n")
819
+ file.write(f" {len(self.text_variables)} # Text Variables\n")
820
+ for i in range(1, len(self.text_variables) + 1):
821
+ file.write(f"{self.text_variables[i]}\n")
822
+ file.write(f" {len(self.data_files)} # Data files\n")
823
+ for i in range(1, 13):
824
+ filename = self.data_files.get(i, "none")
825
+ file.write(f"{filename:<60}\n")
826
+
827
+ return filepath.with_suffix(".smc")
828
+
829
+
830
+ class RuntimeSwitches:
831
+ def __init__(self):
832
+ self.standard_switch_dict = {
833
+ "CC": "Collimator code",
834
+ "DF": "Density file segment",
835
+ "ES": "Energy offset",
836
+ "FE": "Energy resolution file",
837
+ "FZ": "Zubal file",
838
+ "FI": "Input file",
839
+ "FD": "Density map base name",
840
+ "FS": "Source map base name",
841
+ "I2": "Image files stored as 16-bit integer matrices",
842
+ "IN": "Change simind.ini value",
843
+ "LO": "Photon histories before printout",
844
+ "LF": "Linear sampling of polar angle for photon direction",
845
+ "MP": "MPI parallel run",
846
+ "OR": "Change orientation of the density map",
847
+ "PR": "Start simulation at projection number",
848
+ "PU": "Shift of the source in pixel units",
849
+ "QF": "Quit simulation if earlier result file exists",
850
+ "RR": "Random number generator seed",
851
+ "SC": "Maximum number of scatter orders",
852
+ "SF": "Segment for source map",
853
+ "TS": "Time shift for interfile header",
854
+ "UA": "Set density equal to data buffer or 1.0",
855
+ "WB": "Whole-body simulation of anterior and posterior views",
856
+ "Xn": "Change cross sections",
857
+ }
858
+
859
+ self.image_based_switch_dict = {
860
+ "PX": "Pixel size of the source maps",
861
+ "DI": "General direction of the source map",
862
+ "TH": "Slice thickness for the images",
863
+ "SB": "Start block when reading source maps",
864
+ "1S": "Position of the first image to be used",
865
+ "NN": "Multiplier for scaling the number of counts",
866
+ "IF": "Input tumour file",
867
+ }
868
+
869
+ self.myocardiac_switch_dict = {
870
+ "A1": "Shift of the heart in the xy-direction",
871
+ "A2": "Shift of the heart in the yz-direction",
872
+ "A3": "Shift of the heart in the zx-direction",
873
+ "L1": "Location of defect",
874
+ "L2": "Angular size of the defect",
875
+ "L3": "Start from Base",
876
+ "L4": "Extent of defect in axis direction",
877
+ "L5": "Transgression in %",
878
+ "L6": "Activity ratio in defect",
879
+ "M1": "Thickness of the myocardial wall",
880
+ "M2": "Thickness of the plastic wall",
881
+ "M3": "Total length of the chamber",
882
+ "M4": "Total diameter of the chamber",
883
+ }
884
+
885
+ self.multiple_spheres_switch_dict = {
886
+ "C1": "Number of spheres",
887
+ "C2": "Radius of spheres",
888
+ "C3": "Activity of spheres",
889
+ "C4": "Shift of spheres in the x-direction",
890
+ "C5": "Shift of spheres in the y-direction",
891
+ "C6": "Shift of spheres in the z-direction",
892
+ }
893
+ self.switches = {}
894
+
895
+ self.switch_dict = {
896
+ "Standard": self.standard_switch_dict,
897
+ "Image-based": self.image_based_switch_dict,
898
+ "Myocardiac": self.myocardiac_switch_dict,
899
+ "Multiple spheres": self.multiple_spheres_switch_dict,
900
+ }
901
+
902
+ @property
903
+ def combined_switch_dict(self):
904
+ combined_dict = {}
905
+ for sub_dict in self.switch_dict.values():
906
+ combined_dict.update(sub_dict)
907
+ return combined_dict
908
+
909
+ def _set_switch_by_switch(self, switch, value):
910
+ if switch in self.combined_switch_dict:
911
+ self.switches[switch] = value
912
+ else:
913
+ raise ValueError(f"Switch {switch} is not recognised.")
914
+
915
+ def _set_switch_by_name(self, name, value):
916
+ for switch, description in self.combined_switch_dict.items():
917
+ if description == name:
918
+ self.switches[switch] = value
919
+ return
920
+ raise ValueError(f"Switch {name} is not recognised.")
921
+
922
+ def set_switch(self, identifier, value):
923
+ if identifier in self.combined_switch_dict.values():
924
+ self._set_switch_by_name(identifier, value)
925
+ elif identifier in self.combined_switch_dict.keys():
926
+ self._set_switch_by_switch(identifier, value)
927
+ else:
928
+ raise ValueError(f"Switch {identifier} is not recognised.")
929
+
930
+ def print_switches(self):
931
+ for switch, value in self.switches.items():
932
+ description = self.combined_switch_dict[switch]
933
+ print(f"{switch} ({description}): {value}")
934
+
935
+ def print_available_switches(self):
936
+ for switch_dict in self.switch_dict.values():
937
+ print(f"Switches for {switch_dict}:")
938
+ for switch, description in switch_dict.items():
939
+ print(f"{switch}: {description}")