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 +11 -0
- beamio/common/__init__.py +0 -0
- beamio/common/beamdata.py +71 -0
- beamio/common/measurement.py +64 -0
- beamio/rfa/__init__.py +13 -0
- beamio/rfa/factory.py +69 -0
- beamio/rfa/iba.py +417 -0
- beamio/rfa/ptw.py +0 -0
- beamio/tps/__init__.py +0 -0
- beamio/tps/raystation.py +448 -0
- beamio-0.1.0.dist-info/METADATA +140 -0
- beamio-0.1.0.dist-info/RECORD +15 -0
- beamio-0.1.0.dist-info/WHEEL +5 -0
- beamio-0.1.0.dist-info/licenses/LICENSE +339 -0
- beamio-0.1.0.dist-info/top_level.txt +1 -0
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
|