pass-sim 0.6.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 (239) hide show
  1. PASS/__init__.py +32 -0
  2. PASS/__main__.py +5 -0
  3. PASS/analysis/__init__.py +8 -0
  4. PASS/analysis/data_io.py +456 -0
  5. PASS/analysis/dynamic_aperture.py +387 -0
  6. PASS/analysis/fft.py +71 -0
  7. PASS/analysis/fma.py +404 -0
  8. PASS/analysis/refined_fft.py +186 -0
  9. PASS/commands/__init__.py +79 -0
  10. PASS/commands/beam_beam.py +334 -0
  11. PASS/commands/collision/__init__.py +1 -0
  12. PASS/commands/collision/config.py +351 -0
  13. PASS/commands/collision/hourglass.py +184 -0
  14. PASS/commands/collision/interaction.py +441 -0
  15. PASS/commands/collision/luminosity.py +359 -0
  16. PASS/commands/command.py +72 -0
  17. PASS/commands/ecloud/__init__.py +7 -0
  18. PASS/commands/ecloud/buildup.py +481 -0
  19. PASS/commands/ecloud/driver.py +198 -0
  20. PASS/commands/ecloud/fields.py +898 -0
  21. PASS/commands/ecloud/interaction.py +73 -0
  22. PASS/commands/ecloud/io.py +123 -0
  23. PASS/commands/ecloud/pusher.py +610 -0
  24. PASS/commands/ecloud/secondary.py +38 -0
  25. PASS/commands/ecloud/state.py +188 -0
  26. PASS/commands/electron_cloud.py +222 -0
  27. PASS/commands/element/__init__.py +1 -0
  28. PASS/commands/element/bump.py +350 -0
  29. PASS/commands/element/crab_cavity.py +311 -0
  30. PASS/commands/element/crossing_angle.py +234 -0
  31. PASS/commands/element/dipole.py +1373 -0
  32. PASS/commands/element/drift.py +267 -0
  33. PASS/commands/element/electron_cooler.py +812 -0
  34. PASS/commands/element/elseparator.py +1121 -0
  35. PASS/commands/element/error.py +353 -0
  36. PASS/commands/element/exciter.py +501 -0
  37. PASS/commands/element/float_waister.py +15 -0
  38. PASS/commands/element/kicker.py +398 -0
  39. PASS/commands/element/magnet_maps.py +391 -0
  40. PASS/commands/element/marker.py +65 -0
  41. PASS/commands/element/multipole.py +291 -0
  42. PASS/commands/element/octupole.py +268 -0
  43. PASS/commands/element/quadrupole.py +595 -0
  44. PASS/commands/element/ramping.py +60 -0
  45. PASS/commands/element/rfcavity.py +432 -0
  46. PASS/commands/element/sextupole.py +269 -0
  47. PASS/commands/element/solenoid.py +652 -0
  48. PASS/commands/ibs.py +1097 -0
  49. PASS/commands/injection.py +1524 -0
  50. PASS/commands/monitor/__init__.py +1 -0
  51. PASS/commands/monitor/distribution.py +192 -0
  52. PASS/commands/monitor/particle_monitor.py +638 -0
  53. PASS/commands/monitor/phase_advance.py +573 -0
  54. PASS/commands/monitor/slow_extraction_monitor.py +387 -0
  55. PASS/commands/monitor/statistic.py +584 -0
  56. PASS/commands/reorganize.py +77 -0
  57. PASS/commands/slicer.py +1666 -0
  58. PASS/commands/slow_extraction.py +436 -0
  59. PASS/commands/solver/__init__.py +46 -0
  60. PASS/commands/solver/analytic.py +872 -0
  61. PASS/commands/solver/dst_rectangle.py +531 -0
  62. PASS/commands/solver/fd_arbitrary.py +275 -0
  63. PASS/commands/solver/fd_rectangle.py +378 -0
  64. PASS/commands/solver/fft_free_space.py +311 -0
  65. PASS/commands/solver/field_result.py +175 -0
  66. PASS/commands/solver/formula_common.py +12 -0
  67. PASS/commands/solver/formula_gaussian_ellipse.py +57 -0
  68. PASS/commands/solver/formula_gaussian_round.py +30 -0
  69. PASS/commands/solver/formula_parabolic.py +82 -0
  70. PASS/commands/solver/formula_uniform_ellipse.py +67 -0
  71. PASS/commands/solver/formula_uniform_round.py +36 -0
  72. PASS/commands/solver/pic.py +2190 -0
  73. PASS/commands/sort_bunch.py +197 -0
  74. PASS/commands/space_charge.py +807 -0
  75. PASS/commands/transverse_feedback.py +779 -0
  76. PASS/commands/twiss.py +351 -0
  77. PASS/commands/wake/__init__.py +27 -0
  78. PASS/commands/wake/convolution.py +758 -0
  79. PASS/commands/wake/execution.py +121 -0
  80. PASS/commands/wake/quasistatic.py +200 -0
  81. PASS/commands/wake/time_convolution.py +993 -0
  82. PASS/commands/wake/wake_components.py +96 -0
  83. PASS/commands/wake/wake_conventions.py +108 -0
  84. PASS/commands/wake/wake_io.py +54 -0
  85. PASS/commands/wake/wake_models.py +626 -0
  86. PASS/commands/wake/wake_moments.py +438 -0
  87. PASS/commands/wake/wake_periodic.py +142 -0
  88. PASS/commands/wake/wake_solvers.py +949 -0
  89. PASS/commands/wake/wake_spectrum.py +482 -0
  90. PASS/commands/wake/wake_state.py +359 -0
  91. PASS/commands/wake/wake_tfs.py +334 -0
  92. PASS/commands/wake/wake_timing.py +118 -0
  93. PASS/commands/wake/wake_velocity.py +102 -0
  94. PASS/commands/wake/wake_wall.py +97 -0
  95. PASS/commands/wake_field.py +427 -0
  96. PASS/core/__init__.py +1 -0
  97. PASS/core/beam.py +125 -0
  98. PASS/core/bunch.py +158 -0
  99. PASS/core/config.py +713 -0
  100. PASS/core/executor.py +185 -0
  101. PASS/core/particle.py +105 -0
  102. PASS/core/sequence.py +49 -0
  103. PASS/core/simulation.py +41 -0
  104. PASS/core/state.py +9 -0
  105. PASS/core/timing.py +271 -0
  106. PASS/gui/__init__.py +10 -0
  107. PASS/gui/__main__.py +6 -0
  108. PASS/gui/analysis.py +500 -0
  109. PASS/gui/analysis_data.py +360 -0
  110. PASS/gui/analysis_fma.py +104 -0
  111. PASS/gui/analysis_spectrum.py +104 -0
  112. PASS/gui/app.py +5045 -0
  113. PASS/gui/appearance.py +344 -0
  114. PASS/gui/beam_beam_configuration.py +457 -0
  115. PASS/gui/beam_calculator.py +177 -0
  116. PASS/gui/clock.py +13 -0
  117. PASS/gui/conversion_worker.py +64 -0
  118. PASS/gui/documentation.py +152 -0
  119. PASS/gui/dynamic_aperture.py +436 -0
  120. PASS/gui/electron_cloud_configuration.py +479 -0
  121. PASS/gui/electron_cloud_results.py +179 -0
  122. PASS/gui/exciter_calculator.py +194 -0
  123. PASS/gui/feedback_fir.py +138 -0
  124. PASS/gui/file_drop.py +106 -0
  125. PASS/gui/help.py +311 -0
  126. PASS/gui/jobs.py +123 -0
  127. PASS/gui/magnet_ramping.py +505 -0
  128. PASS/gui/optics.py +98 -0
  129. PASS/gui/optics_calculator.py +333 -0
  130. PASS/gui/parameter_preview.py +278 -0
  131. PASS/gui/parameters.py +815 -0
  132. PASS/gui/plot_analysis.py +616 -0
  133. PASS/gui/plotting.py +954 -0
  134. PASS/gui/project.py +646 -0
  135. PASS/gui/property_state.py +253 -0
  136. PASS/gui/recovery.py +189 -0
  137. PASS/gui/results.py +124 -0
  138. PASS/gui/rf_bucket.py +127 -0
  139. PASS/gui/run_page.py +878 -0
  140. PASS/gui/runner.py +71 -0
  141. PASS/gui/scan_grid.py +132 -0
  142. PASS/gui/structured.py +746 -0
  143. PASS/gui/tool_beam.py +513 -0
  144. PASS/gui/tool_conversion.py +777 -0
  145. PASS/gui/tool_emittance.py +453 -0
  146. PASS/gui/tool_exciter.py +281 -0
  147. PASS/gui/tool_formulas.py +124 -0
  148. PASS/gui/tool_magnets.py +165 -0
  149. PASS/gui/tool_math.py +79 -0
  150. PASS/gui/tool_optics_formulas.py +158 -0
  151. PASS/gui/tool_physics_common.py +325 -0
  152. PASS/gui/tool_rf.py +136 -0
  153. PASS/gui/tool_tune.py +536 -0
  154. PASS/gui/tools.py +216 -0
  155. PASS/gui/validation.py +176 -0
  156. PASS/gui/wake_configuration.py +136 -0
  157. PASS/gui/wake_import.py +718 -0
  158. PASS/gui/widgets.py +105 -0
  159. PASS/gui/workspace.py +1046 -0
  160. PASS/main.py +385 -0
  161. PASS/para/__init__.py +1 -0
  162. PASS/para/api.py +438 -0
  163. PASS/para/madx.py +828 -0
  164. PASS/para/schema/__init__.py +89 -0
  165. PASS/para/schema/bunch.py +327 -0
  166. PASS/para/schema/electron_cloud.py +196 -0
  167. PASS/para/schema/elements.py +722 -0
  168. PASS/para/schema/ibs.py +232 -0
  169. PASS/para/schema/main.py +160 -0
  170. PASS/para/schema/monitors.py +107 -0
  171. PASS/para/schema/rf.py +29 -0
  172. PASS/para/schema/sequence.py +100 -0
  173. PASS/para/schema/slicer.py +45 -0
  174. PASS/para/schema/slow_extraction.py +56 -0
  175. PASS/para/schema/space_charge.py +272 -0
  176. PASS/para/schema/transverse_feedback.py +164 -0
  177. PASS/para/schema/twiss.py +48 -0
  178. PASS/para/schema/wake_field.py +387 -0
  179. PASS/para/smooth.py +117 -0
  180. PASS/para/tools/__init__.py +1 -0
  181. PASS/para/tools/data_converter.py +384 -0
  182. PASS/para/tools/exciter_data.py +45 -0
  183. PASS/para/tools/hiaf_rf.py +330 -0
  184. PASS/para/tools/ramping.py +226 -0
  185. PASS/para/tools/rf_data.py +66 -0
  186. PASS/para/twiss_interpolation.py +227 -0
  187. PASS/plot/__init__.py +1 -0
  188. PASS/plot/plot_bucket.py +121 -0
  189. PASS/plot/plot_distribution.py +453 -0
  190. PASS/plot/plot_dynamic_aperture.py +227 -0
  191. PASS/plot/plot_main.py +91 -0
  192. PASS/plot/plot_twiss_ellipse.py +46 -0
  193. PASS/runner/__init__.py +1 -0
  194. PASS/tool/__init__.py +1 -0
  195. PASS/tool/calc_Brho.py +134 -0
  196. PASS/tool/calc_beamPower.py +106 -0
  197. PASS/tool/calc_bucket.py +181 -0
  198. PASS/tool/data_conversion.py +640 -0
  199. PASS/tool/hdf5_io.py +259 -0
  200. PASS/tool/io_example.py +64 -0
  201. PASS/tool/mass_catalog.json +138335 -0
  202. PASS/tool/particle_masses.py +83 -0
  203. PASS/tool/particles.py +489 -0
  204. PASS/tool/sdds_io.py +374 -0
  205. PASS/tool/tune_diagram.py +295 -0
  206. PASS/tool/wake_conversion.py +475 -0
  207. PASS/utils/__init__.py +1 -0
  208. PASS/utils/aperture.py +728 -0
  209. PASS/utils/bump_waveform.py +103 -0
  210. PASS/utils/command_order.py +106 -0
  211. PASS/utils/compute_kinematics.py +37 -0
  212. PASS/utils/constants.py +63 -0
  213. PASS/utils/coordinates.py +47 -0
  214. PASS/utils/helper.py +25 -0
  215. PASS/utils/injection_io.py +130 -0
  216. PASS/utils/input_snapshot.py +186 -0
  217. PASS/utils/logger.py +92 -0
  218. PASS/utils/magnet_program.py +285 -0
  219. PASS/utils/particle_monitor_io.py +189 -0
  220. PASS/utils/particle_monitor_read.py +237 -0
  221. PASS/utils/program.py +177 -0
  222. PASS/utils/reference_clock.py +155 -0
  223. PASS/utils/sc_coverage.py +181 -0
  224. PASS/utils/scan_grid.py +43 -0
  225. PASS/utils/slicing.py +213 -0
  226. PASS/utils/table_io.py +159 -0
  227. PASS/validation/__init__.py +5 -0
  228. PASS/validation/__main__.py +22 -0
  229. PASS/validation/files.py +347 -0
  230. PASS/validation/geometry.py +49 -0
  231. PASS/validation/relations.py +588 -0
  232. PASS/validation/report.py +106 -0
  233. PASS/validation/rules.py +815 -0
  234. pass_sim-0.6.0.dist-info/METADATA +230 -0
  235. pass_sim-0.6.0.dist-info/RECORD +239 -0
  236. pass_sim-0.6.0.dist-info/WHEEL +5 -0
  237. pass_sim-0.6.0.dist-info/entry_points.txt +3 -0
  238. pass_sim-0.6.0.dist-info/licenses/LICENSE +201 -0
  239. pass_sim-0.6.0.dist-info/top_level.txt +1 -0
PASS/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+ from pathlib import Path
3
+ import tomllib
4
+
5
+
6
+ def _source_project_metadata():
7
+ """Read metadata only from the checkout containing this imported package."""
8
+ path = Path(__file__).resolve().parent.parent / "pyproject.toml"
9
+ try:
10
+ with path.open("rb") as stream:
11
+ project = tomllib.load(stream).get("project", {})
12
+ if isinstance(project, dict) and project.get("name") == "pass-sim":
13
+ return project
14
+ except (OSError, ValueError):
15
+ pass
16
+ return {}
17
+
18
+
19
+ def _package_version():
20
+ source_version = _source_project_metadata().get("version")
21
+ if isinstance(source_version, str) and source_version.strip():
22
+ return source_version
23
+ try:
24
+ return version("pass-sim")
25
+ except PackageNotFoundError:
26
+ return "unknown"
27
+
28
+
29
+ __version__ = _package_version()
30
+
31
+ # Enforce explicit imports to avoid 'from module import *'
32
+ __all__ = []
PASS/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Run PASS JSON inputs or a .passproj project with python -m PASS."""
2
+ from PASS.main import cli_main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(cli_main())
@@ -0,0 +1,8 @@
1
+ """Numerical spectral and particle-survival diagnostics."""
2
+
3
+ from .fft import compute_fft
4
+ from .refined_fft import compute_refined_fft
5
+ from .fma import compute_fma
6
+ from .dynamic_aperture import compute_dynamic_aperture, export_dynamic_aperture, read_dynamic_aperture
7
+
8
+ __all__ = ["compute_fft", "compute_refined_fft", "compute_fma", "compute_dynamic_aperture", "read_dynamic_aperture", "export_dynamic_aperture"]
@@ -0,0 +1,456 @@
1
+ """Explicit numeric signal selection for offline spectral analysis.
2
+
3
+ Algorithms consume arrays; this module handles file layout and sampling checks.
4
+ No particles are regrouped, missing samples filled, or lost tails interpolated.
5
+ """
6
+
7
+ from contextlib import contextmanager
8
+ import csv
9
+ import json
10
+ from pathlib import Path
11
+ import shlex
12
+ import zipfile
13
+
14
+ import numpy as np
15
+
16
+
17
+ def _format(path):
18
+ suffix = Path(path).suffix.lower()
19
+ formats = {
20
+ ".csv": "csv",
21
+ ".tsv": "tsv",
22
+ ".txt": "text",
23
+ ".dat": "text",
24
+ ".tfs": "tfs",
25
+ ".h5": "hdf5",
26
+ ".hdf5": "hdf5",
27
+ ".hdf": "hdf5",
28
+ ".npy": "npy",
29
+ ".npz": "npz"
30
+ }
31
+ if suffix not in formats:
32
+ raise ValueError("Supported signal files: CSV, TSV, TXT, DAT, TFS, HDF5, NPY and NPZ")
33
+ return formats[suffix]
34
+
35
+
36
+ def _native(value):
37
+ if isinstance(value, np.ndarray):
38
+ return [_native(item) for item in value.tolist()] if value.ndim else _native(value.item())
39
+ if isinstance(value, np.generic):
40
+ return _native(value.item())
41
+ if isinstance(value, bytes):
42
+ return value.decode("utf-8", errors="replace")
43
+ if isinstance(value, dict):
44
+ return {str(k): _native(v) for k, v in value.items()}
45
+ if isinstance(value, (tuple, list)):
46
+ return [_native(item) for item in value]
47
+ if isinstance(value, complex):
48
+ return {"real": value.real, "imag": value.imag}
49
+ return value
50
+
51
+
52
+ def _numeric(values):
53
+ """Parse an entire column without dropping any row on conversion failure."""
54
+ stripped = [value.strip() for value in values]
55
+ if stripped and all(value.lower() in {"true", "false"} for value in stripped):
56
+ return np.asarray([value.lower() == "true" for value in stripped], dtype=bool)
57
+ converted = ["nan" if not value else value for value in stripped]
58
+ for dtype in (np.int64, np.float64, np.complex128):
59
+ try:
60
+ return np.asarray(converted, dtype=dtype)
61
+ except (ValueError, OverflowError):
62
+ continue
63
+ return np.asarray(values, dtype=str)
64
+
65
+
66
+ def _read_text(path, kind, delimiter, skip_rows, header):
67
+ if isinstance(skip_rows, bool) or not isinstance(skip_rows, (int, np.integer)) or skip_rows < 0:
68
+ raise ValueError("skip_rows must be a nonnegative integer")
69
+ if isinstance(header, bool):
70
+ header = "present" if header else "absent"
71
+ if header not in {"auto", "present", "absent"}:
72
+ raise ValueError("header must be 'auto', 'present' or 'absent'")
73
+ if delimiter in (None, ""):
74
+ delimiter = {"csv": ",", "tsv": "\t"}.get(kind)
75
+ if delimiter in ("whitespace", r"\s+"):
76
+ delimiter = None
77
+ if delimiter == r"\t":
78
+ delimiter = "\t"
79
+ if delimiter is not None and (not isinstance(delimiter, str) or len(delimiter) != 1):
80
+ raise ValueError("delimiter must be one character or 'whitespace'")
81
+ comments = []
82
+ rows = []
83
+ with Path(path).open(encoding="utf-8-sig", newline="") as stream:
84
+ for line_index, line in enumerate(stream):
85
+ if line_index < skip_rows or not line.strip():
86
+ continue
87
+ if line.lstrip().startswith(("#", "!")):
88
+ comments.append(line.rstrip())
89
+ continue
90
+ values = shlex.split(line, comments=False) if delimiter is None else next(csv.reader([line], delimiter=delimiter, strict=True))
91
+ rows.append(values)
92
+ if not rows:
93
+ raise ValueError("The text file has no data rows after skipped lines and comments")
94
+ has_header = header == "present" or (header == "auto" and any(_numeric([value]).dtype.kind not in "biufc" for value in rows[0]))
95
+ names = [name.strip() for name in rows.pop(0)] if has_header else [f"column_{index}" for index in range(len(rows[0]))]
96
+ if not names or any(not name for name in names) or len(set(names)) != len(names):
97
+ raise ValueError("Column names must be nonempty and unique")
98
+ if any(len(row) != len(names) for row in rows):
99
+ raise ValueError("Text rows have different numbers of columns; no rows were discarded")
100
+ metadata = {"header": names if has_header else None, "delimiter": delimiter, "skip_rows": int(skip_rows), "comments": comments}
101
+ return {name: _numeric([row[index] for row in rows]) for index, name in enumerate(names)}, metadata
102
+
103
+
104
+ def _read_table(path, kind, delimiter, skip_rows, header):
105
+ if kind == "tfs" or (kind == "csv" and Path(str(path) + ".metadata.json").exists()):
106
+ if delimiter not in (None, "", ",") or skip_rows or header not in ("auto", "present", True):
107
+ raise ValueError("TFS and CSV metadata sidecars require their original header and delimiter")
108
+ if kind == "tfs":
109
+ from PASS.utils.particle_monitor_read import particle_monitor_tfs_metadata, read_particle_monitor_tfs
110
+
111
+ if particle_monitor_tfs_metadata(path) is not None:
112
+ records, parameters = read_particle_monitor_tfs(path)
113
+ selected = records["record"] == 1
114
+ shape = (parameters["ValidSamples"], parameters["MaxTag"])
115
+ columns = {name: values[selected].reshape(shape) for name, values in records.items() if name not in {"record", "turn", "particle_id"}}
116
+ columns["turn"] = records["turn"][selected].reshape(shape)[:, 0]
117
+ columns["particle_id"] = np.arange(1, shape[1] + 1, dtype=np.int64)
118
+ return columns, {"parameters": parameters, "notices": ["Initial records excluded; samples grouped by particle_id"]}
119
+ from PASS.tool.data_conversion import read_tables
120
+
121
+ table = next(read_tables(path))
122
+ metadata = dict(table.metadata)
123
+ metadata["notices"] = list(table.notices)
124
+ return table.columns, metadata
125
+ return _read_text(path, kind, delimiter, skip_rows, header)
126
+
127
+
128
+ def _array_description(name, shape, dtype, attributes=None):
129
+ return {"name": name, "shape": None if shape is None else list(shape), "dtype": str(dtype), "attributes": _native(attributes or {})}
130
+
131
+
132
+ def _npy_header(stream):
133
+ version = np.lib.format.read_magic(stream)
134
+ if version == (1, 0):
135
+ return np.lib.format.read_array_header_1_0(stream)
136
+ if version in ((2, 0), (3, 0)):
137
+ # Numeric, unstructured headers are ASCII in both versions.
138
+ return np.lib.format.read_array_header_2_0(stream)
139
+ raise ValueError(f"Unsupported NPY header version {version}; save a plain numeric NumPy array")
140
+
141
+
142
+ def inspect_data(path, *, delimiter=None, skip_rows=0, header="auto"):
143
+ """List numeric arrays/columns, shapes and metadata without loading HDF5 data.
144
+
145
+ NPY/NPZ inspection reads array headers only. Text/TFS inspection reads the
146
+ table. Nonnumeric and scalar entries appear in ``excluded`` rather than
147
+ selectable ``arrays``. HDF5 dataset names are absolute paths. A standalone
148
+ NPY array is named ``data``. Headerless text columns are ``column_0``, etc.
149
+ """
150
+ path = Path(path)
151
+ kind = _format(path)
152
+ arrays = []
153
+ excluded = []
154
+ metadata = {}
155
+
156
+ def add(name, shape, dtype, attributes=None):
157
+ description = _array_description(name, shape, dtype, attributes)
158
+ if dtype.kind in "biufc" and shape is not None and len(shape) >= 1:
159
+ arrays.append(description)
160
+ else:
161
+ description["reason"] = "A signal requires a numeric nonscalar array"
162
+ excluded.append(description)
163
+
164
+ if kind == "hdf5":
165
+ import h5py
166
+
167
+ with h5py.File(path, "r") as stream:
168
+ metadata = {"parameters": _native(dict(stream.attrs)), "groups": {}}
169
+
170
+ def visit(name, item):
171
+ if isinstance(item, h5py.Dataset):
172
+ add("/" + name, item.shape, item.dtype, dict(item.attrs))
173
+ elif item.attrs:
174
+ metadata["groups"]["/" + name] = _native(dict(item.attrs))
175
+
176
+ stream.visititems(visit)
177
+ elif kind == "npy":
178
+ with path.open("rb") as stream:
179
+ shape, _, dtype = _npy_header(stream)
180
+ add("data", shape, dtype)
181
+ elif kind == "npz":
182
+ with zipfile.ZipFile(path) as archive:
183
+ for name in archive.namelist():
184
+ if not name.endswith(".npy"):
185
+ continue
186
+ with archive.open(name) as stream:
187
+ shape, _, dtype = _npy_header(stream)
188
+ add(name[:-4], shape, dtype)
189
+ else:
190
+ columns, metadata = _read_table(path, kind, delimiter, skip_rows, header)
191
+ for name, array in columns.items():
192
+ add(name, array.shape, array.dtype)
193
+ metadata.update({"source": str(path.resolve()), "format": kind})
194
+ return {"arrays": arrays, "excluded": excluded, "metadata": _native(metadata)}
195
+
196
+
197
+ @contextmanager
198
+ def _open_data(path, kind, delimiter, skip_rows, header):
199
+ if kind == "hdf5":
200
+ import h5py
201
+
202
+ with h5py.File(path, "r") as stream:
203
+ yield stream, {"parameters": _native(dict(stream.attrs))}
204
+ elif kind == "npy":
205
+ array = np.load(path, mmap_mode="r", allow_pickle=False)
206
+ try:
207
+ yield {"data": array}, {}
208
+ finally:
209
+ if isinstance(array, np.memmap):
210
+ array._mmap.close()
211
+ elif kind == "npz":
212
+ with np.load(path, allow_pickle=False) as archive:
213
+ yield archive, {}
214
+ else:
215
+ yield _read_table(path, kind, delimiter, skip_rows, header)
216
+
217
+
218
+ def _get_array(source, name):
219
+ if not isinstance(name, str) or not name:
220
+ raise ValueError("Select a named numeric column or dataset")
221
+ if name not in source:
222
+ raise ValueError(f"Unknown signal column/dataset: {name}")
223
+ try:
224
+ array = source[name]
225
+ except ValueError as error:
226
+ raise ValueError(f"Cannot read {name!r} as a numeric array without pickle: {error}") from error
227
+ if not hasattr(array, "dtype") or array.dtype.kind not in "biufc" or array.shape is None or len(array.shape) < 1:
228
+ raise ValueError(f"{name!r} must contain a numeric nonscalar array; object/string/structured arrays are unsupported")
229
+ return array
230
+
231
+
232
+ def _hdf5_selection_metadata(array, metadata):
233
+ """Restore PASS table provenance only along the selected dataset's path."""
234
+ metadata = dict(metadata)
235
+ groups = []
236
+ parent = array.parent
237
+ while True:
238
+ groups.append(parent)
239
+ if parent.name == "/":
240
+ break
241
+ parent = parent.parent
242
+ metadata["group_attributes"] = {}
243
+ for group in reversed(groups):
244
+ attributes = _native(dict(group.attrs))
245
+ metadata["group_attributes"][group.name] = attributes
246
+ packed = attributes.get("PASS_CONVERSION_METADATA")
247
+ if packed:
248
+ from PASS.tool.data_conversion import _restore_json
249
+
250
+ restored = _restore_json(json.loads(packed))
251
+ if not isinstance(restored, dict):
252
+ raise ValueError("PASS_CONVERSION_METADATA must contain a metadata object")
253
+ metadata.update(restored)
254
+ return metadata
255
+
256
+
257
+ def _bounds(value, length, label):
258
+ if value is None:
259
+ return 0, length
260
+ if not isinstance(value, (tuple, list)) or len(value) != 2:
261
+ raise ValueError(f"{label} must be [start, end), with two indices")
262
+ start, end = value
263
+ start = 0 if start is None else start
264
+ end = length if end is None else end
265
+ if any(isinstance(item, (bool, np.bool_)) or not isinstance(item, (int, np.integer)) for item in (start, end)):
266
+ raise ValueError(f"{label} indices must be integers")
267
+ if not 0 <= start < end <= length:
268
+ raise ValueError(f"{label} must satisfy 0 <= start < end <= {length}")
269
+ return int(start), int(end)
270
+
271
+
272
+ def _select_array(array, sample_axis, sample_range, object_range):
273
+ if isinstance(sample_axis, (bool, np.bool_)) or not isinstance(sample_axis, (int, np.integer)):
274
+ raise ValueError("sample_axis must be an integer")
275
+ if not -array.ndim <= sample_axis < array.ndim:
276
+ raise ValueError(f"sample_axis is outside the {array.ndim} array dimensions")
277
+ sample_axis %= array.ndim
278
+ start, end = _bounds(sample_range, array.shape[sample_axis], "sample_range")
279
+ object_shape = tuple(length for axis, length in enumerate(array.shape) if axis != sample_axis)
280
+ n_objects = int(np.prod(object_shape)) if object_shape else 1
281
+ if n_objects == 0 or end - start < 2:
282
+ raise ValueError("Select at least one signal with at least two samples")
283
+ indices = [slice(None)] * array.ndim
284
+ indices[sample_axis] = slice(start, end)
285
+ if object_range is None:
286
+ signal = np.moveaxis(np.asarray(array[tuple(indices)]), sample_axis, -1).copy()
287
+ object_ids = np.arange(n_objects).reshape(object_shape)
288
+ else:
289
+ first, last = _bounds(object_range, n_objects, "object_range")
290
+ if not object_shape:
291
+ signal = np.asarray(array[tuple(indices)]).copy().reshape(1, end - start)
292
+ elif len(object_shape) == 1:
293
+ indices[1 - sample_axis] = slice(first, last)
294
+ signal = np.moveaxis(np.asarray(array[tuple(indices)]), sample_axis, -1).copy()
295
+ else:
296
+ # Read only requested objects; HDF5 cannot pair fancy indices on all axes.
297
+ rows = []
298
+ for index in range(first, last):
299
+ coordinates = iter(np.unravel_index(index, object_shape))
300
+ indices = [slice(start, end) if axis == sample_axis else next(coordinates) for axis in range(array.ndim)]
301
+ rows.append(np.asarray(array[tuple(indices)]))
302
+ signal = np.stack(rows)
303
+ object_ids = np.arange(first, last)
304
+ return signal, object_ids, sample_axis, (start, end)
305
+
306
+
307
+ def _sampling_coordinates(source, coordinate, n_original, bounds, sample_spacing, *, allow_tail=False):
308
+ start, end = bounds
309
+ if coordinate is None:
310
+ if isinstance(sample_spacing, (bool, np.bool_)) or not np.isscalar(sample_spacing):
311
+ raise ValueError("sample_spacing must be a finite positive number")
312
+ try:
313
+ spacing = float(sample_spacing)
314
+ except (TypeError, ValueError, OverflowError) as error:
315
+ raise ValueError("sample_spacing must be a finite positive number") from error
316
+ if not np.isfinite(spacing) or spacing <= 0:
317
+ raise ValueError("sample_spacing must be a finite positive number")
318
+ return np.arange(start, end, dtype=float) * spacing, spacing
319
+ values = _get_array(source, coordinate)
320
+ shape_matches = values.ndim == 1 and (values.shape[0] >= n_original if allow_tail else values.shape[0] == n_original)
321
+ if not shape_matches or values.dtype.kind == "c":
322
+ raise ValueError("The sample coordinate must be a real 1-D array matching the original sampling axis")
323
+ coordinates = np.asarray(values[start:end], dtype=np.float64)
324
+ if not np.all(np.isfinite(coordinates)):
325
+ raise ValueError("Sample coordinates contain nonfinite values")
326
+ differences = np.diff(coordinates)
327
+ if np.any(differences <= 0):
328
+ raise ValueError(
329
+ "Sample coordinates must increase strictly; repeated turns may indicate multiple particles. Select/group one trajectory first")
330
+ spacing = float(np.median(differences))
331
+ # Large absolute epochs must not make a missing sample look like roundoff.
332
+ tolerance = min(max(spacing * 1e-9, np.max(np.abs(coordinates)) * np.finfo(float).eps * 8), spacing * 1e-6)
333
+ if not np.all(np.abs(differences - spacing) <= tolerance):
334
+ raise ValueError("Sample coordinates are not uniformly spaced or their numeric precision is insufficient; select a continuous interval")
335
+ return coordinates.copy(), spacing
336
+
337
+
338
+ def load_signal(path,
339
+ selection,
340
+ *,
341
+ sample_axis=-1,
342
+ sample_range=None,
343
+ object_range=None,
344
+ coordinate=None,
345
+ sample_spacing=1.0,
346
+ delimiter=None,
347
+ skip_rows=0,
348
+ header="auto",
349
+ alive_selection=None):
350
+ """Read a numeric signal and return samples on its last axis.
351
+
352
+ ``selection`` and ``coordinate`` name explicit columns or datasets. The
353
+ sampling coordinate, when supplied, determines the returned spacing and
354
+ overrides ``sample_spacing``. Otherwise spacing is in caller-chosen units.
355
+ All ranges are half-open Python indices, not turn/time coordinate values.
356
+ ``object_range`` selects flattened nonsampling dimensions in C order and
357
+ produces ``(objects, samples)``; omitting it preserves leading dimensions.
358
+
359
+ Text delimiter defaults are comma for CSV, tab for TSV and whitespace for
360
+ TXT/DAT. ``header`` is auto/present/absent (True/False also work), and
361
+ ``skip_rows`` counts physical initial lines. TFS and CSV metadata sidecars
362
+ reuse PASS's typed readers and preserve their metadata.
363
+
364
+ ``alive_selection`` explicitly names a real mask or signed tag (>0 live),
365
+ with the signal's original shape or one value per sample. PASS Particle
366
+ Monitor tables automatically check their tag column. Lost/unavailable
367
+ samples cause an error; select a complete live interval to analyze them.
368
+ NumPy object arrays are never unpickled. HDF5 and NPY selections are sliced
369
+ before copying; compressed NPZ and text tables require loading the member.
370
+ Single-file PASS ParticleMonitor data always use axis 0 for samples;
371
+ their particle IDs replace generic object indices in the result.
372
+ """
373
+ path = Path(path)
374
+ kind = _format(path)
375
+ with _open_data(path, kind, delimiter, skip_rows, header) as (source, metadata):
376
+ array = _get_array(source, selection)
377
+ if kind == "hdf5":
378
+ metadata = _hdf5_selection_metadata(array, metadata)
379
+ parameters = metadata.get("parameters", {})
380
+ monitor = parameters.get("Name") == "PASS Particle Monitor"
381
+ single_file = monitor and parameters.get("Layout") == "single_file"
382
+ if monitor and parameters.get("Layout") not in {"single_file", "table"}:
383
+ raise ValueError("ParticleMonitor requires the current single-file format")
384
+ if single_file:
385
+ if array.ndim != 2 or (kind == "hdf5" and array.parent.name != "/"):
386
+ raise ValueError("Select a root turn-by-turn dataset from single-file ParticleMonitor output")
387
+ if any(name not in source for name in ("particle_id", "turn", "tag")):
388
+ raise ValueError("ParticleMonitor requires particle_id, turn and tag datasets")
389
+ committed = parameters.get("ValidSamples", -1)
390
+ if (parameters.get("FormatVersion") != 2 or isinstance(committed, (bool, np.bool_)) or not isinstance(committed, (int, np.integer))
391
+ or not 0 <= committed <= array.shape[0]):
392
+ raise ValueError("Unsupported or incomplete single-file ParticleMonitor output")
393
+ sample_range = _bounds(sample_range, committed, "sample_range")
394
+ sample_axis = 0
395
+ signal, object_ids, axis, bounds = _select_array(array, sample_axis, sample_range, object_range)
396
+ if single_file:
397
+ particle_ids = _get_array(source, "particle_id")
398
+ if particle_ids.ndim != 1 or particle_ids.shape[0] != array.shape[1] or particle_ids.dtype.kind not in "iu":
399
+ raise ValueError("ParticleMonitor requires one integer particle_id per trajectory column")
400
+ object_ids = np.asarray(particle_ids[int(object_ids[0]):int(object_ids[-1]) + 1])
401
+ if np.any(object_ids <= 0) or np.any(object_ids > np.iinfo(np.int64).max) or len(np.unique(object_ids)) != len(object_ids):
402
+ raise ValueError("ParticleMonitor requires unique positive int64 particle IDs")
403
+ prefix = array.parent.name.rstrip("/") + "/" if kind == "hdf5" and array.parent.name != "/" else ""
404
+ turn_selection = prefix + "turn"
405
+ tag_selection = prefix + "tag"
406
+ if monitor:
407
+ if coordinate is None and turn_selection in source:
408
+ coordinate = turn_selection
409
+ if alive_selection is None and tag_selection in source:
410
+ alive_selection = tag_selection
411
+ coordinates, spacing = _sampling_coordinates(source,
412
+ coordinate,
413
+ committed if single_file else array.shape[axis],
414
+ bounds,
415
+ sample_spacing,
416
+ allow_tail=single_file)
417
+ if alive_selection is not None:
418
+ alive = _get_array(source, alive_selection)
419
+ if alive.dtype.kind == "c":
420
+ raise ValueError("Alive masks must be real, with positive values marking live samples")
421
+ if alive.shape == array.shape or (single_file and alive.ndim == 2 and alive.shape[0] >= committed and alive.shape[1] == array.shape[1]):
422
+ live_values = _select_array(alive, sample_axis, sample_range, object_range)[0]
423
+ elif alive.shape == (array.shape[axis], ):
424
+ live_values = np.asarray(alive[bounds[0]:bounds[1]])
425
+ else:
426
+ raise ValueError("Alive mask shape must match the signal or its sample coordinate")
427
+ if not np.all(np.isfinite(live_values) & (live_values > 0)):
428
+ raise ValueError("Selection contains lost/unavailable samples; choose a continuous live interval before loss")
429
+ if not np.all(np.isfinite(signal)):
430
+ raise ValueError("Selection contains nonfinite signal samples; choose a complete continuous interval (rows are never dropped)")
431
+ if monitor:
432
+ tags = (live_values if alive_selection == tag_selection else _select_array(source[tag_selection], sample_axis, sample_range,
433
+ object_range)[0]) if tag_selection in source else None
434
+ if single_file and (tags is None or tags.dtype.kind not in "iu" or tags.shape != signal.shape or np.any(tags != object_ids[..., None])):
435
+ # A caller-supplied mask cannot revive losses or relabel a PM column.
436
+ raise ValueError(
437
+ "ParticleMonitor tags must identify the selected live particles; selection contains lost/unavailable or misidentified samples")
438
+ if tags is not None and np.any(tags != tags[..., :1]):
439
+ raise ValueError("A Particle Monitor signal must follow one particle tag; group trajectories explicitly")
440
+ metadata.update({
441
+ "source": str(path.resolve()),
442
+ "format": kind,
443
+ "selection": selection,
444
+ "original_shape": list(array.shape),
445
+ "sample_axis": int(axis),
446
+ "sample_range": list(bounds),
447
+ "object_range": object_range,
448
+ "coordinate": coordinate,
449
+ "spacing_source": "coordinate" if coordinate is not None else "sample_spacing",
450
+ "alive_selection": alive_selection
451
+ })
452
+ if hasattr(array, "attrs"):
453
+ metadata["dataset_attributes"] = _native(dict(array.attrs))
454
+ if coordinate is not None and hasattr(source[coordinate], "attrs"):
455
+ metadata["coordinate_attributes"] = _native(dict(source[coordinate].attrs))
456
+ return {"signal": signal, "sample_spacing": spacing, "sample_coordinates": coordinates, "object_ids": object_ids, "metadata": _native(metadata)}