beamio 0.1.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.
beamio/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Input, output, and conversion of radiotherapy beam measurements."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("beamio")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.0.0.dev"
9
+
10
+ __author__ = "Phillip Duncan-Gelder"
11
+ __all__ = ["__version__", "__author__"]
File without changes
@@ -0,0 +1,71 @@
1
+ from abc import ABC, abstractmethod
2
+ import os
3
+ import copy
4
+
5
+ import numpy as np
6
+
7
+ from beamio.common.measurement import Measurement
8
+
9
+
10
+ class BeamData(ABC):
11
+ """ Abstract base class for beam data files. """
12
+
13
+ def __init__(self, file_path):
14
+ """Initialize the BeamData class.
15
+
16
+ Args:
17
+ file_path (str): Path to the beam data file.
18
+ """
19
+ self.file_path = file_path
20
+ self.measurements: list[Measurement] = []
21
+ self.file_meta = {} # Dictionary to hold any file-level metadata (e.g., machine settings, date, etc.)
22
+
23
+ @abstractmethod
24
+ def parse(self) -> 'BeamData':
25
+ """Parse the beam data file.
26
+
27
+ Args:
28
+ file_path (str): Path to the beam data file.
29
+ Returns:
30
+ 'BeamData': An instance of the BeamData class with parsed data.
31
+ """
32
+ pass
33
+
34
+ @abstractmethod
35
+ def save(self, output_path: str) -> None:
36
+ """Save the beam data to a specified output path.
37
+
38
+ Args:
39
+ output_path (str): Path to save the beam data file.
40
+ """
41
+ pass
42
+
43
+ def convert(self, target_cls: 'BeamData', target_path="") -> 'BeamData':
44
+ """Convert the beam data to a different format.
45
+
46
+ Args:
47
+ target_cls (BeamData): The target format to convert to (e.g., 'IBAAccept6', 'RayStation').
48
+ target_path (str): Optional path, really just used if parse() called, but this shouldn't be the case
49
+ Returns:
50
+ 'BeamData': An instance of the BeamData class in the target format.
51
+ """
52
+ # This method can be implemented to use specific conversion logic based on the target format
53
+
54
+ out = target_cls(target_path)
55
+
56
+ # Copy measurements and metadata to the new instance
57
+ out.measurements = copy.deepcopy(self.measurements) # This is a deep copy to ensure independent instances
58
+ out.file_meta = copy.deepcopy(self.file_meta)
59
+ return out
60
+
61
+
62
+ # ---------- Common helper methods for subclasses can be added here ----------
63
+
64
+ @staticmethod
65
+ def is_numeric(s: str) -> bool:
66
+ """Check if a string can be converted to a float."""
67
+ try:
68
+ float(s)
69
+ return True
70
+ except ValueError:
71
+ return False
@@ -0,0 +1,64 @@
1
+ import numpy as np
2
+ from enum import Enum
3
+ from typing import Tuple
4
+
5
+ class RadiationType(Enum):
6
+ PHOTON = 'Photon'
7
+ ELECTRON = 'Electron'
8
+ PROTON = 'Proton'
9
+
10
+ class MeasurementType(Enum):
11
+ DepthDose = 'DepthDose'
12
+ CrosslineProfile = 'CrosslineProfile'
13
+ InlineProfile = 'InlineProfile'
14
+ DiagonalProfile = 'DiagonalProfile'
15
+
16
+
17
+ class Measurement:
18
+ def __init__(self):
19
+ """Initialize the Measurement class."""
20
+
21
+ self.doses: np.ndarray = np.array([])
22
+ self.positions: np.ndarray = np.array([])
23
+
24
+ self.type: MeasurementType | None = None
25
+ self.radiation_type: RadiationType | None = None
26
+ self.energy: float | None = None
27
+ self.SSD: float | None = None
28
+ self.field_size: Tuple[float, float] | None = None
29
+ self.wedge_angle: float | None = None
30
+
31
+ self.startpoint: Tuple[float, float, float] | None = None
32
+ self.endpoint: Tuple[float, float, float] | None = None
33
+
34
+ # Other metadata fields depending on the file format can be added here
35
+ # These will not be used in generic methods using measurement data,
36
+ # but can be useful for format-specific processing or saving
37
+ self.metadata: dict = {}
38
+
39
+ # ---------- Public methods/helpers ----------
40
+
41
+ def reconstruct_xyz(self) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
42
+ """Reconstruct X, Y, Z positions from the stored positions and startpoint/endpoint."""
43
+ if self.startpoint is None or self.endpoint is None:
44
+ raise ValueError("Startpoint and endpoint must be defined to reconstruct XYZ positions.")
45
+
46
+ pos = np.asarray(self.positions, dtype=float)
47
+ xs, ys, zs = self.startpoint
48
+ xe, ye, ze = self.endpoint
49
+ X = np.full_like(pos, xs, dtype=float)
50
+ Y = np.full_like(pos, ys, dtype=float)
51
+ Z = np.full_like(pos, zs, dtype=float)
52
+ if self.type == MeasurementType.DepthDose:
53
+ Z = pos
54
+ elif self.type == MeasurementType.CrosslineProfile:
55
+ X = pos
56
+ elif self.type == MeasurementType.InlineProfile:
57
+ Y = pos
58
+ elif self.type == MeasurementType.DiagonalProfile:
59
+ # For diagonal, we stored signed distance along diagonal in positions; need to convert back to X/Y
60
+ angle_rad = np.arctan2(ye - ys, xe - xs)
61
+ X = pos * np.sin(angle_rad)
62
+ Y = pos * np.cos(angle_rad)
63
+
64
+ return X, Y, Z
beamio/rfa/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """The Vendor module contains submodules and classes for interfacing with various
2
+ third-party applications for data handling (mostly input) (e.g., loading of IBA myQA Accept data)"""
3
+
4
+ from beamio.rfa.iba import IBAAccept6
5
+ from beamio.common.beamdata import BeamData
6
+ from beamio.rfa.factory import BeamDataFactory
7
+
8
+
9
+ __all__ = [
10
+ 'IBAAccept6',
11
+ 'BeamData',
12
+ 'BeamDataFactory'
13
+ ]
beamio/rfa/factory.py ADDED
@@ -0,0 +1,69 @@
1
+ import os
2
+
3
+ from beamio.rfa.iba import IBAAccept6
4
+ from beamio.common.beamdata import BeamData
5
+
6
+ # --------------------------------------------------------------------------
7
+ # Each key is a BeamData subclass
8
+ # Each value is a dict with:
9
+ # 'all' → list of keywords that must ALL be present
10
+ # 'any' → list of keywords where at least one must be present
11
+ # 'file_extensions' → list of valid file extensions (without the leading dot e.g., 'asc', 'txt')
12
+ # --------------------------------------------------------------------------
13
+ BEAMDATA_KEYWORDS: dict[type[BeamData], dict[str, list[str]]] = {
14
+ IBAAccept6: {
15
+ "all": [r"# Measurement number"],
16
+ "any": [r":MSR", r"%FSZ", r"%STS", r"%EDS"],
17
+ "file_extensions": ["asc"],
18
+ },
19
+ # PTWBeamScan: {
20
+ # "all": ["BEGIN_SCAN_DATA"],
21
+ # "any": ["BeamScan"],
22
+ # },
23
+ }
24
+
25
+
26
+ class BeamDataFactory:
27
+ """Keyword-based BeamData detector and loader."""
28
+
29
+ @staticmethod
30
+ def from_file(file_path: str) -> BeamData:
31
+ """Identify and load the correct BeamData subclass based on file contents."""
32
+ with open(file_path, "r", errors="ignore") as f:
33
+ head = f.read(4096)
34
+
35
+ for cls, rules in BEAMDATA_KEYWORDS.items():
36
+ all_keys = rules.get("all", [])
37
+ any_keys = rules.get("any", [])
38
+
39
+ # Check "all" keywords
40
+ if all_keys and not all(k in head for k in all_keys):
41
+ continue
42
+
43
+ # Check "any" keywords
44
+ if any_keys and not any(k in head for k in any_keys):
45
+ continue
46
+
47
+ # Match found
48
+ return cls(file_path).parse()
49
+
50
+ raise ValueError(f"Unsupported or unrecognized beam data file: {file_path}")
51
+
52
+ @staticmethod
53
+ def is_supported_file(file_path: str) -> bool:
54
+ """Check if the file is a supported BeamData file based on its extension."""
55
+ ext = os.path.splitext(file_path)[1].lstrip(".").lower()
56
+ for rules in BEAMDATA_KEYWORDS.values():
57
+ if "file_extensions" in rules and ext in rules["file_extensions"]:
58
+ return True
59
+ return False
60
+
61
+ @staticmethod
62
+ def get_supported_extensions() -> list[str]:
63
+ """Get a list of all supported file extensions for BeamData files."""
64
+ extensions = set()
65
+ for rules in BEAMDATA_KEYWORDS.values():
66
+ exts = rules.get("file_extensions", [])
67
+ extensions.update(exts)
68
+ return sorted(extensions)
69
+
beamio/rfa/iba.py ADDED
@@ -0,0 +1,417 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from typing import Callable, Dict, List, Optional, Tuple
5
+
6
+ import numpy as np
7
+
8
+ from beamio.common.measurement import Measurement, MeasurementType, RadiationType
9
+ from beamio.common.beamdata import BeamData
10
+
11
+
12
+ class IBAAccept6(BeamData):
13
+ """
14
+ IBA myQA Accept 6.X ASCII measurement dump (RFA300 BDS format) parser/writer.
15
+
16
+ - parse(path): if ASCII, calls _parse_ascii_file
17
+ - save(path): if .asc, calls _save_ascii_file
18
+
19
+ Uses a mapping layer from IBA %KEY parameters to your Measurement fields.
20
+ """
21
+
22
+ # ---------- Mapping layer ----------
23
+ #
24
+ # Each entry maps IBA %KEY -> (measurement_attr, converter)
25
+ # Converters take the raw string (without the leading %KEY) and return the typed value.
26
+ #
27
+ # Keep this small and focused: only what your generic model uses.
28
+ # Everything else remains in Measurement.metadata for round-tripping.
29
+ #
30
+ IBA_TO_MEASUREMENT: Dict[str, Tuple[str, Callable[[str], object]]] = {}
31
+
32
+ MEASUREMENT_TO_IBA: Dict[str, Tuple[str, Callable[[object], str]]] = {}
33
+
34
+ FILE_META_DEFAULTS: Dict[str, str] = {
35
+ "MSR": "0",
36
+ "SYS": "BDS 0",
37
+ }
38
+
39
+ RADIATION_TYPE_MAPPING: Dict[str, RadiationType] = {
40
+ "PHO": RadiationType.PHOTON,
41
+ "ELE": RadiationType.ELECTRON,
42
+ "PRO": RadiationType.PROTON,
43
+ }
44
+
45
+ def __init__(self, file_path: str) -> None:
46
+ super().__init__(file_path)
47
+ self._init_mappings()
48
+
49
+ def _init_mappings(self) -> None:
50
+ # Build once per instance (could also be class-level constants).
51
+ def parse_float(s: str) -> float:
52
+ return float(s.strip().replace(",", "."))
53
+
54
+ def parse_field_size(s: str) -> Tuple[float, float]:
55
+ parts = s.split()
56
+ return (parse_float(parts[0]), parse_float(parts[1]))
57
+
58
+ def parse_triplet(s: str) -> Tuple[float | str, float | str, float | str]:
59
+ # strip inline comment
60
+ if "#" in s:
61
+ s = s.split("#", 1)[0].strip()
62
+ parts = s.split()
63
+ return tuple(parse_float(p) if self.is_numeric(p) else p for p in parts)
64
+
65
+ # Forward mapping: %KEY -> Measurement attribute
66
+ self.IBA_TO_MEASUREMENT = {
67
+ "SSD": ("SSD", parse_float),
68
+ "FSZ": ("field_size", parse_field_size),
69
+ "STS": ("startpoint", parse_triplet),
70
+ "EDS": ("endpoint", parse_triplet),
71
+ "WEG": ("wedge_angle", parse_float),
72
+ }
73
+
74
+ # Reverse mapping for saving: Measurement attribute -> %KEY
75
+ def fmt_float(x: object) -> str:
76
+ return f"{float(x):.6g}"
77
+
78
+ def fmt_field_size(fs: object) -> str:
79
+ a, b = fs # type: ignore[misc]
80
+ return f"{fmt_float(a)}\t{fmt_float(b)}"
81
+
82
+ def fmt_triplet(tp: object) -> str:
83
+ x, y, z = tp # type: ignore[misc]
84
+ return f"{fmt_float(x)}\t{fmt_float(y)}\t{fmt_float(z)}"
85
+
86
+ self.MEASUREMENT_TO_IBA = {
87
+ "SSD": ("SSD", fmt_float),
88
+ "field_size": ("FSZ", fmt_field_size),
89
+ "startpoint": ("STS", fmt_triplet),
90
+ "endpoint": ("EDS", fmt_triplet),
91
+ "wedge_angle": ("WEG", fmt_float),
92
+ }
93
+
94
+ # ---------- Public API ----------
95
+
96
+ def parse(self) -> "IBAAccept6":
97
+ _, ext = os.path.splitext(self.file_path)
98
+ if ext.lower() == '.asc':
99
+ self._parse_ascii_file(self.file_path)
100
+ else:
101
+ raise ValueError(f'Unsupported file extension: {ext}')
102
+ return self
103
+
104
+ def save(self, filepath: str) -> None:
105
+ """
106
+ Save current self.measurements. If filepath ends with .asc, write IBA ASCII.
107
+ """
108
+ ext = os.path.splitext(filepath)[1].lower()
109
+ if ext == ".asc":
110
+ self._save_ascii_file(filepath)
111
+ return
112
+ raise ValueError(f"Unsupported output format for IBAAccept6: {filepath}")
113
+
114
+ # ---------- ASCII parsing ----------
115
+
116
+ def _parse_ascii_file(self, filepath: str) -> None:
117
+ with open(filepath, "r", encoding="utf-8", errors="replace") as f:
118
+ lines = f.read().splitlines()
119
+
120
+ self.measurements.clear()
121
+ self.file_meta = {} # reset per parse
122
+
123
+ current_block: List[str] = []
124
+
125
+ for raw in lines:
126
+ s = raw.strip()
127
+ if not s:
128
+ continue
129
+
130
+ if s.startswith(":MSR"):
131
+ parts = s.split()
132
+ if len(parts) >= 2:
133
+ self.file_meta["MSR"] = parts[1]
134
+ continue
135
+
136
+ if s.startswith(":SYS"):
137
+ self.file_meta["SYS"] = " ".join(s.split()[1:])
138
+ continue
139
+
140
+ if s.startswith(":EOF"):
141
+ if current_block:
142
+ self._parse_measurement_block(current_block)
143
+ current_block = []
144
+ break
145
+
146
+ current_block.append(raw)
147
+
148
+ if s.startswith(":EOM"):
149
+ self._parse_measurement_block(current_block)
150
+ current_block = []
151
+
152
+ if current_block:
153
+ self._parse_measurement_block(current_block)
154
+
155
+ def _parse_measurement_block(self, block_lines: List[str]) -> None:
156
+ meta: Dict[str, str] = {}
157
+
158
+ # 1) Gather all %KEY lines
159
+ for ln in block_lines:
160
+ s = ln.strip()
161
+ if not s.startswith("%"):
162
+ continue
163
+ parts = s.split(None, 1)
164
+ key = parts[0][1:].strip()
165
+ val = parts[1].strip() if len(parts) > 1 else ""
166
+ meta[key] = val
167
+
168
+ # 2) Parse data rows '=' => X Y Z Dose
169
+ xs, ys, zs, ds = [], [], [], []
170
+ for ln in block_lines:
171
+ s = ln.strip()
172
+ if not s.startswith("="):
173
+ continue
174
+ payload = s[1:].strip()
175
+ if "#" in payload:
176
+ payload = payload.split("#", 1)[0].strip()
177
+ parts = payload.split()
178
+ if len(parts) < 4:
179
+ continue
180
+ x = float(parts[0])
181
+ y = float(parts[1])
182
+ z = float(parts[2])
183
+ d = float("nan") if parts[3].lower() == "nan" else float(parts[3])
184
+ xs.append(x)
185
+ ys.append(y)
186
+ zs.append(z)
187
+ ds.append(d)
188
+
189
+ if not ds:
190
+ return
191
+
192
+ x_arr = np.asarray(xs, dtype=float)
193
+ y_arr = np.asarray(ys, dtype=float)
194
+ z_arr = np.asarray(zs, dtype=float)
195
+ d_arr = np.asarray(ds, dtype=float)
196
+
197
+ m = Measurement()
198
+ m.metadata = meta
199
+
200
+ # 3) Apply mapping: %KEY -> Measurement fields (generic)
201
+ for iba_key, (attr, conv) in self.IBA_TO_MEASUREMENT.items():
202
+ if iba_key in meta and meta[iba_key].strip():
203
+ try:
204
+ setattr(m, attr, conv(meta[iba_key]))
205
+ except Exception:
206
+ # keep raw in metadata; don't hard fail unless you want to
207
+ pass
208
+
209
+ # 3.5) Round startpoint to nearest 0.2 mm if present, to mitigate precision/uncertainties
210
+ if m.startpoint is not None:
211
+ m.startpoint = tuple(round(coord * 5) / 5 for coord in m.startpoint)
212
+ # If abs(startpoint) <= 0.2 mm, set to 0.0 to avoid noise around zero
213
+ m.startpoint = tuple(0.0 if abs(coord) <= 0.2 else coord for coord in m.startpoint)
214
+
215
+ # 4) Special hook for BMT -> radiation_type, energy
216
+ if "BMT" in meta:
217
+ bmt = meta["BMT"]
218
+ try:
219
+ m.radiation_type = self._parse_bmt_radiation(bmt)
220
+ except Exception:
221
+ pass
222
+ try:
223
+ m.energy = self._parse_bmt_energy(bmt)
224
+ except Exception:
225
+ pass
226
+
227
+ # 5) Determine measurement type + positions
228
+ scn = (meta.get("SCN") or "").strip().upper()
229
+
230
+ # Which coordinate varies the most?
231
+ rx = float(np.nanmax(x_arr) - np.nanmin(x_arr))
232
+ ry = float(np.nanmax(y_arr) - np.nanmin(y_arr))
233
+ rz = float(np.nanmax(z_arr) - np.nanmin(z_arr))
234
+
235
+ # Assign, DEPTH, DIAGONAL, otherwise crossline/inline
236
+ if scn == "DPT" or (rz >= max(rx, ry) and rz > 0.5):
237
+ m.type = MeasurementType.DepthDose
238
+ m.positions = z_arr
239
+ elif scn == "DIA":
240
+ m.type = MeasurementType.DiagonalProfile
241
+ # For diagonal, we can store the distance along the diagonal as positions
242
+ m.positions = np.sqrt(x_arr**2 + y_arr**2) * np.sign(x_arr) # signed distance along diagonal
243
+ else:
244
+ if rx >= ry:
245
+ m.type = MeasurementType.CrosslineProfile
246
+ m.positions = x_arr
247
+ else:
248
+ m.type = MeasurementType.InlineProfile
249
+ m.positions = y_arr
250
+
251
+ # Doses
252
+ m.doses = d_arr
253
+
254
+ # Optional: trim leading/trailing NaNs in dose (common for depth scans)
255
+ m.positions, m.doses = self._trim_nan_pairs(m.positions, m.doses)
256
+
257
+ # Only store unique positions (remove duplicates if any, keeping first occurrence)
258
+ _, unique_indices = np.unique(m.positions, return_index=True)
259
+ m.positions = m.positions[unique_indices]
260
+ m.doses = m.doses[unique_indices]
261
+
262
+ self.measurements.append(m)
263
+
264
+ def _parse_bmt_radiation(self, bmt: str) -> RadiationType:
265
+ token = bmt.strip().split()[0].upper()
266
+ return self.RADIATION_TYPE_MAPPING.get(token, None)
267
+
268
+ def _parse_bmt_energy(self, bmt: str) -> float:
269
+ parts = bmt.strip().split()
270
+ if len(parts) < 2:
271
+ raise ValueError(f"Invalid BMT value: {bmt!r}")
272
+ return float(parts[1].replace(",", ".")) # handle comma as decimal separator if present (European formats)
273
+
274
+ def _trim_nan_pairs(
275
+ self,
276
+ positions: np.ndarray,
277
+ doses: np.ndarray
278
+ ) -> Tuple[np.ndarray, np.ndarray]:
279
+ """
280
+ Remove all pairs where either positions or doses are NaN/inf.
281
+
282
+ Args:
283
+ positions (np.ndarray): Array of position values.
284
+ doses (np.ndarray): Array of dose values.
285
+ Returns:
286
+ Tuple[np.ndarray, np.ndarray]: Filtered positions and doses with NaNs removed.
287
+ """
288
+ if positions.size == 0 or doses.size == 0:
289
+ return positions, doses
290
+
291
+ if positions.shape != doses.shape:
292
+ raise ValueError("positions and doses must have identical shapes")
293
+
294
+ # True only where BOTH arrays are finite
295
+ valid = np.isfinite(positions) & np.isfinite(doses)
296
+
297
+ # Fast exit if nothing survives
298
+ if not np.any(valid):
299
+ return np.array([], dtype=positions.dtype), np.array([], dtype=doses.dtype)
300
+
301
+ return positions[valid], doses[valid]
302
+
303
+ # ---------- ASCII saving ----------
304
+
305
+ def _save_ascii_file(self, filepath: str) -> None:
306
+ """
307
+ Write an IBA-style ASCII file.
308
+
309
+ Philosophy:
310
+ - Preserve metadata where available (m.metadata)
311
+ - Ensure required keys exist for round-tripping
312
+ - Recreate the data rows as '= X Y Z Dose' using startpoint and varying axis
313
+ """
314
+ out: List[str] = []
315
+
316
+ out.append(f":MSR \t{len(self.measurements)}\t # No. of measurement in file")
317
+ out.append(f":SYS \t{self.file_meta.get('SYS', self.FILE_META_DEFAULTS['SYS'])} # Beam Data Scanner System")
318
+ out.append("#")
319
+ out.append("# RFA300 ASCII Measurement Dump ( BDS format )")
320
+ out.append("#")
321
+
322
+ for idx, m in enumerate(self.measurements, start=1):
323
+ out.append(f"# Measurement number \t{idx}")
324
+ out.append("#")
325
+
326
+ # Start from stored metadata if present; otherwise build minimal meta.
327
+ meta = dict(getattr(m, "metadata", {}) or {})
328
+
329
+ # Ensure basic keys exist / updated from measurement fields.
330
+ meta.setdefault("VNR", "1.0")
331
+ meta.setdefault("MOD", "RAT")
332
+ meta.setdefault("TYP", "SCN")
333
+ # SCN depends on type
334
+ if m.type == MeasurementType.DepthDose:
335
+ meta["SCN"] = "DPT"
336
+ else:
337
+ meta["SCN"] = "PRO"
338
+ meta.setdefault("FLD", "SEM")
339
+
340
+ # BMT from radiation_type + energy if available
341
+ if m.radiation_type is not None and m.energy is not None:
342
+ meta["BMT"] = self._format_bmt(m.radiation_type, m.energy)
343
+
344
+ # Apply reverse mapping for known fields
345
+ for attr, (iba_key, fmt) in self.MEASUREMENT_TO_IBA.items():
346
+ val = getattr(m, attr, None)
347
+ if val is None:
348
+ continue
349
+ meta[iba_key] = fmt(val)
350
+
351
+ # FSZ/SSD etc. now in meta if present
352
+
353
+ # PTS
354
+ meta["PTS"] = str(int(m.doses.size))
355
+
356
+ # STS / EDS for scan start/end
357
+ # If startpoint missing, infer from first coordinate with other axes = 0
358
+ sts = m.startpoint
359
+ eds = m.endpoint
360
+
361
+ meta["STS"] = self._fmt_triplet(sts) + " # Start Scan values in mm ( X , Y , Z )"
362
+ meta["EDS"] = self._fmt_triplet(eds) + " # End Scan values in mm ( X , Y , Z )"
363
+
364
+ # Write %KEY lines in a reasonable order; keep extras at end
365
+ preferred_order = [
366
+ "VNR", "MOD", "TYP", "SCN", "FLD", "DAT", "TIM", "FSZ", "BMT", "SSD",
367
+ "BUP", "BRD", "FSH", "ASC", "WEG", "GPO", "CPO", "MEA", "PRD", "PTS", "STS", "EDS"
368
+ ]
369
+ written = set()
370
+
371
+ for k in preferred_order:
372
+ if k in meta:
373
+ out.append(f"%{k} \t{meta[k]}")
374
+ written.add(k)
375
+
376
+ # Write remaining metadata keys (stable order)
377
+ for k in sorted(meta.keys()):
378
+ if k in written:
379
+ continue
380
+ out.append(f"%{k} \t{meta[k]}")
381
+
382
+ out.append("#")
383
+ out.append("#\t X Y Z Dose")
384
+ out.append("#")
385
+
386
+ # Data lines: reconstruct full X,Y,Z for each point
387
+ X, Y, Z = m.reconstruct_xyz()
388
+ # round to nearest 0.1 mm for cleaner output and to mitigate precision issues
389
+ X, Y, Z = np.round(X * 10) / 10, np.round(Y * 10) / 10, np.round(Z * 10) / 10
390
+ D = np.asarray(m.doses, dtype=float)
391
+
392
+ for x, y, z, d in zip(X, Y, Z, D):
393
+ if np.isnan(d):
394
+ d_str = "NaN"
395
+ else:
396
+ d_str = f"{d:.6g}"
397
+ out.append(f"= \t{float(x):.6g}\t{float(y):.6g}\t{float(z):.6g}\t{d_str}")
398
+
399
+ out.append(":EOM # End of Measurement")
400
+
401
+ if idx < len(self.measurements):
402
+ out.append("#")
403
+ out.append("# RFA300 ASCII Measurement Dump ( BDS format )")
404
+ out.append("#")
405
+
406
+ out.append(":EOF # End of File")
407
+
408
+ with open(filepath, "w", encoding="utf-8", newline="\n") as f:
409
+ f.write("\n".join(out))
410
+
411
+ def _format_bmt(self, rad: RadiationType, energy: float) -> str:
412
+ rtm_reversed = {v: k for k, v in self.RADIATION_TYPE_MAPPING.items()}
413
+ tok = rtm_reversed.get(rad)
414
+ return f"{tok}\t {float(energy):.6g}"
415
+
416
+ def _fmt_triplet(self, p: Tuple[float, float, float]) -> str:
417
+ return f"{p[0]:.6g}\t{p[1]:.6g}\t{p[2]:.6g}"
beamio/rfa/ptw.py ADDED
File without changes
beamio/tps/__init__.py ADDED
File without changes