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.
- PASS/__init__.py +32 -0
- PASS/__main__.py +5 -0
- PASS/analysis/__init__.py +8 -0
- PASS/analysis/data_io.py +456 -0
- PASS/analysis/dynamic_aperture.py +387 -0
- PASS/analysis/fft.py +71 -0
- PASS/analysis/fma.py +404 -0
- PASS/analysis/refined_fft.py +186 -0
- PASS/commands/__init__.py +79 -0
- PASS/commands/beam_beam.py +334 -0
- PASS/commands/collision/__init__.py +1 -0
- PASS/commands/collision/config.py +351 -0
- PASS/commands/collision/hourglass.py +184 -0
- PASS/commands/collision/interaction.py +441 -0
- PASS/commands/collision/luminosity.py +359 -0
- PASS/commands/command.py +72 -0
- PASS/commands/ecloud/__init__.py +7 -0
- PASS/commands/ecloud/buildup.py +481 -0
- PASS/commands/ecloud/driver.py +198 -0
- PASS/commands/ecloud/fields.py +898 -0
- PASS/commands/ecloud/interaction.py +73 -0
- PASS/commands/ecloud/io.py +123 -0
- PASS/commands/ecloud/pusher.py +610 -0
- PASS/commands/ecloud/secondary.py +38 -0
- PASS/commands/ecloud/state.py +188 -0
- PASS/commands/electron_cloud.py +222 -0
- PASS/commands/element/__init__.py +1 -0
- PASS/commands/element/bump.py +350 -0
- PASS/commands/element/crab_cavity.py +311 -0
- PASS/commands/element/crossing_angle.py +234 -0
- PASS/commands/element/dipole.py +1373 -0
- PASS/commands/element/drift.py +267 -0
- PASS/commands/element/electron_cooler.py +812 -0
- PASS/commands/element/elseparator.py +1121 -0
- PASS/commands/element/error.py +353 -0
- PASS/commands/element/exciter.py +501 -0
- PASS/commands/element/float_waister.py +15 -0
- PASS/commands/element/kicker.py +398 -0
- PASS/commands/element/magnet_maps.py +391 -0
- PASS/commands/element/marker.py +65 -0
- PASS/commands/element/multipole.py +291 -0
- PASS/commands/element/octupole.py +268 -0
- PASS/commands/element/quadrupole.py +595 -0
- PASS/commands/element/ramping.py +60 -0
- PASS/commands/element/rfcavity.py +432 -0
- PASS/commands/element/sextupole.py +269 -0
- PASS/commands/element/solenoid.py +652 -0
- PASS/commands/ibs.py +1097 -0
- PASS/commands/injection.py +1524 -0
- PASS/commands/monitor/__init__.py +1 -0
- PASS/commands/monitor/distribution.py +192 -0
- PASS/commands/monitor/particle_monitor.py +638 -0
- PASS/commands/monitor/phase_advance.py +573 -0
- PASS/commands/monitor/slow_extraction_monitor.py +387 -0
- PASS/commands/monitor/statistic.py +584 -0
- PASS/commands/reorganize.py +77 -0
- PASS/commands/slicer.py +1666 -0
- PASS/commands/slow_extraction.py +436 -0
- PASS/commands/solver/__init__.py +46 -0
- PASS/commands/solver/analytic.py +872 -0
- PASS/commands/solver/dst_rectangle.py +531 -0
- PASS/commands/solver/fd_arbitrary.py +275 -0
- PASS/commands/solver/fd_rectangle.py +378 -0
- PASS/commands/solver/fft_free_space.py +311 -0
- PASS/commands/solver/field_result.py +175 -0
- PASS/commands/solver/formula_common.py +12 -0
- PASS/commands/solver/formula_gaussian_ellipse.py +57 -0
- PASS/commands/solver/formula_gaussian_round.py +30 -0
- PASS/commands/solver/formula_parabolic.py +82 -0
- PASS/commands/solver/formula_uniform_ellipse.py +67 -0
- PASS/commands/solver/formula_uniform_round.py +36 -0
- PASS/commands/solver/pic.py +2190 -0
- PASS/commands/sort_bunch.py +197 -0
- PASS/commands/space_charge.py +807 -0
- PASS/commands/transverse_feedback.py +779 -0
- PASS/commands/twiss.py +351 -0
- PASS/commands/wake/__init__.py +27 -0
- PASS/commands/wake/convolution.py +758 -0
- PASS/commands/wake/execution.py +121 -0
- PASS/commands/wake/quasistatic.py +200 -0
- PASS/commands/wake/time_convolution.py +993 -0
- PASS/commands/wake/wake_components.py +96 -0
- PASS/commands/wake/wake_conventions.py +108 -0
- PASS/commands/wake/wake_io.py +54 -0
- PASS/commands/wake/wake_models.py +626 -0
- PASS/commands/wake/wake_moments.py +438 -0
- PASS/commands/wake/wake_periodic.py +142 -0
- PASS/commands/wake/wake_solvers.py +949 -0
- PASS/commands/wake/wake_spectrum.py +482 -0
- PASS/commands/wake/wake_state.py +359 -0
- PASS/commands/wake/wake_tfs.py +334 -0
- PASS/commands/wake/wake_timing.py +118 -0
- PASS/commands/wake/wake_velocity.py +102 -0
- PASS/commands/wake/wake_wall.py +97 -0
- PASS/commands/wake_field.py +427 -0
- PASS/core/__init__.py +1 -0
- PASS/core/beam.py +125 -0
- PASS/core/bunch.py +158 -0
- PASS/core/config.py +713 -0
- PASS/core/executor.py +185 -0
- PASS/core/particle.py +105 -0
- PASS/core/sequence.py +49 -0
- PASS/core/simulation.py +41 -0
- PASS/core/state.py +9 -0
- PASS/core/timing.py +271 -0
- PASS/gui/__init__.py +10 -0
- PASS/gui/__main__.py +6 -0
- PASS/gui/analysis.py +500 -0
- PASS/gui/analysis_data.py +360 -0
- PASS/gui/analysis_fma.py +104 -0
- PASS/gui/analysis_spectrum.py +104 -0
- PASS/gui/app.py +5045 -0
- PASS/gui/appearance.py +344 -0
- PASS/gui/beam_beam_configuration.py +457 -0
- PASS/gui/beam_calculator.py +177 -0
- PASS/gui/clock.py +13 -0
- PASS/gui/conversion_worker.py +64 -0
- PASS/gui/documentation.py +152 -0
- PASS/gui/dynamic_aperture.py +436 -0
- PASS/gui/electron_cloud_configuration.py +479 -0
- PASS/gui/electron_cloud_results.py +179 -0
- PASS/gui/exciter_calculator.py +194 -0
- PASS/gui/feedback_fir.py +138 -0
- PASS/gui/file_drop.py +106 -0
- PASS/gui/help.py +311 -0
- PASS/gui/jobs.py +123 -0
- PASS/gui/magnet_ramping.py +505 -0
- PASS/gui/optics.py +98 -0
- PASS/gui/optics_calculator.py +333 -0
- PASS/gui/parameter_preview.py +278 -0
- PASS/gui/parameters.py +815 -0
- PASS/gui/plot_analysis.py +616 -0
- PASS/gui/plotting.py +954 -0
- PASS/gui/project.py +646 -0
- PASS/gui/property_state.py +253 -0
- PASS/gui/recovery.py +189 -0
- PASS/gui/results.py +124 -0
- PASS/gui/rf_bucket.py +127 -0
- PASS/gui/run_page.py +878 -0
- PASS/gui/runner.py +71 -0
- PASS/gui/scan_grid.py +132 -0
- PASS/gui/structured.py +746 -0
- PASS/gui/tool_beam.py +513 -0
- PASS/gui/tool_conversion.py +777 -0
- PASS/gui/tool_emittance.py +453 -0
- PASS/gui/tool_exciter.py +281 -0
- PASS/gui/tool_formulas.py +124 -0
- PASS/gui/tool_magnets.py +165 -0
- PASS/gui/tool_math.py +79 -0
- PASS/gui/tool_optics_formulas.py +158 -0
- PASS/gui/tool_physics_common.py +325 -0
- PASS/gui/tool_rf.py +136 -0
- PASS/gui/tool_tune.py +536 -0
- PASS/gui/tools.py +216 -0
- PASS/gui/validation.py +176 -0
- PASS/gui/wake_configuration.py +136 -0
- PASS/gui/wake_import.py +718 -0
- PASS/gui/widgets.py +105 -0
- PASS/gui/workspace.py +1046 -0
- PASS/main.py +385 -0
- PASS/para/__init__.py +1 -0
- PASS/para/api.py +438 -0
- PASS/para/madx.py +828 -0
- PASS/para/schema/__init__.py +89 -0
- PASS/para/schema/bunch.py +327 -0
- PASS/para/schema/electron_cloud.py +196 -0
- PASS/para/schema/elements.py +722 -0
- PASS/para/schema/ibs.py +232 -0
- PASS/para/schema/main.py +160 -0
- PASS/para/schema/monitors.py +107 -0
- PASS/para/schema/rf.py +29 -0
- PASS/para/schema/sequence.py +100 -0
- PASS/para/schema/slicer.py +45 -0
- PASS/para/schema/slow_extraction.py +56 -0
- PASS/para/schema/space_charge.py +272 -0
- PASS/para/schema/transverse_feedback.py +164 -0
- PASS/para/schema/twiss.py +48 -0
- PASS/para/schema/wake_field.py +387 -0
- PASS/para/smooth.py +117 -0
- PASS/para/tools/__init__.py +1 -0
- PASS/para/tools/data_converter.py +384 -0
- PASS/para/tools/exciter_data.py +45 -0
- PASS/para/tools/hiaf_rf.py +330 -0
- PASS/para/tools/ramping.py +226 -0
- PASS/para/tools/rf_data.py +66 -0
- PASS/para/twiss_interpolation.py +227 -0
- PASS/plot/__init__.py +1 -0
- PASS/plot/plot_bucket.py +121 -0
- PASS/plot/plot_distribution.py +453 -0
- PASS/plot/plot_dynamic_aperture.py +227 -0
- PASS/plot/plot_main.py +91 -0
- PASS/plot/plot_twiss_ellipse.py +46 -0
- PASS/runner/__init__.py +1 -0
- PASS/tool/__init__.py +1 -0
- PASS/tool/calc_Brho.py +134 -0
- PASS/tool/calc_beamPower.py +106 -0
- PASS/tool/calc_bucket.py +181 -0
- PASS/tool/data_conversion.py +640 -0
- PASS/tool/hdf5_io.py +259 -0
- PASS/tool/io_example.py +64 -0
- PASS/tool/mass_catalog.json +138335 -0
- PASS/tool/particle_masses.py +83 -0
- PASS/tool/particles.py +489 -0
- PASS/tool/sdds_io.py +374 -0
- PASS/tool/tune_diagram.py +295 -0
- PASS/tool/wake_conversion.py +475 -0
- PASS/utils/__init__.py +1 -0
- PASS/utils/aperture.py +728 -0
- PASS/utils/bump_waveform.py +103 -0
- PASS/utils/command_order.py +106 -0
- PASS/utils/compute_kinematics.py +37 -0
- PASS/utils/constants.py +63 -0
- PASS/utils/coordinates.py +47 -0
- PASS/utils/helper.py +25 -0
- PASS/utils/injection_io.py +130 -0
- PASS/utils/input_snapshot.py +186 -0
- PASS/utils/logger.py +92 -0
- PASS/utils/magnet_program.py +285 -0
- PASS/utils/particle_monitor_io.py +189 -0
- PASS/utils/particle_monitor_read.py +237 -0
- PASS/utils/program.py +177 -0
- PASS/utils/reference_clock.py +155 -0
- PASS/utils/sc_coverage.py +181 -0
- PASS/utils/scan_grid.py +43 -0
- PASS/utils/slicing.py +213 -0
- PASS/utils/table_io.py +159 -0
- PASS/validation/__init__.py +5 -0
- PASS/validation/__main__.py +22 -0
- PASS/validation/files.py +347 -0
- PASS/validation/geometry.py +49 -0
- PASS/validation/relations.py +588 -0
- PASS/validation/report.py +106 -0
- PASS/validation/rules.py +815 -0
- pass_sim-0.6.0.dist-info/METADATA +230 -0
- pass_sim-0.6.0.dist-info/RECORD +239 -0
- pass_sim-0.6.0.dist-info/WHEEL +5 -0
- pass_sim-0.6.0.dist-info/entry_points.txt +3 -0
- pass_sim-0.6.0.dist-info/licenses/LICENSE +201 -0
- 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,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"]
|
PASS/analysis/data_io.py
ADDED
|
@@ -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)}
|