plesty-pol-pl 0.2.0.dev1__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.
- plesty/pol_pl/__init__.py +27 -0
- plesty/pol_pl/__main__.py +295 -0
- plesty/pol_pl/config.yaml +67 -0
- plesty/pol_pl/experiment.py +542 -0
- plesty/pol_pl/rig.py +260 -0
- plesty/pol_pl/viz.py +157 -0
- plesty_pol_pl-0.2.0.dev1.dist-info/METADATA +123 -0
- plesty_pol_pl-0.2.0.dev1.dist-info/RECORD +11 -0
- plesty_pol_pl-0.2.0.dev1.dist-info/WHEEL +4 -0
- plesty_pol_pl-0.2.0.dev1.dist-info/licenses/LICENSE +184 -0
- plesty_pol_pl-0.2.0.dev1.dist-info/licenses/LICENSES/LGPL-3.0-or-later.txt +165 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Yunshuang Yuan
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
"""Polarization-dependent photoluminescence experiment module."""
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from plesty.lib.utils.config import module_config
|
|
9
|
+
|
|
10
|
+
from plesty.pol_pl.experiment import Experiment
|
|
11
|
+
from plesty.pol_pl.rig import PolPlRig
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Experiment",
|
|
15
|
+
"PolPlRig",
|
|
16
|
+
"load_config",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_config() -> dict[str, Any]:
|
|
21
|
+
"""Load the packaged module configuration (constants and running defaults).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
The parsed ``config.yaml`` shipped with this module.
|
|
25
|
+
"""
|
|
26
|
+
config: dict[str, Any] = module_config(__name__)
|
|
27
|
+
return config
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Yunshuang Yuan
|
|
2
|
+
# SPDX-License-Identifier: LGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
"""Entry point for running the polarization-dependent PL experiment as a standalone process.
|
|
5
|
+
|
|
6
|
+
Device addresses and the shared disk are deployment facts and come from the
|
|
7
|
+
environment (``HWP_ADDRESS``, ``SPEC_ADDRESS``, ``PLESTY_DATA``). The bench
|
|
8
|
+
running the experiment serves each instrument on the bench ``setup.yaml`` names
|
|
9
|
+
for it and gives the run its address; ``ExperimentCLI`` sets ``PLESTY_DATA`` at
|
|
10
|
+
start-up from ``setup.yaml`` and the values its variables hold in ``.env``; one
|
|
11
|
+
set by hand still wins. Measurement tuning lives in a YAML configuration file.
|
|
12
|
+
|
|
13
|
+
``ExperimentCLI`` owns everything that is the same for every experiment —
|
|
14
|
+
``--config``, ``--resume``, ``--run-root``, ``--dry-run`` and ``--manifest``,
|
|
15
|
+
the preflight, the event loop, and the graceful stop. This module declares only
|
|
16
|
+
what differs: :data:`SCHEMA`, what the run reads from the environment, and how
|
|
17
|
+
the rig and the experiment are built.
|
|
18
|
+
|
|
19
|
+
:data:`MANIFEST` is the contract. ``python -m plesty.pol_pl --manifest`` prints
|
|
20
|
+
it, and a console builds this experiment's settings form from it without being
|
|
21
|
+
taught anything about this module. It and ``config/default.yaml`` must declare
|
|
22
|
+
the same keys — gate e3 checks that they do.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import sys
|
|
26
|
+
from copy import deepcopy
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
from plesty.fss import HARMONICS
|
|
30
|
+
from plesty.lib.device.params import ConfigParameter
|
|
31
|
+
from plesty.lib.experiment import (
|
|
32
|
+
EnvVar,
|
|
33
|
+
Experiment as BaseExperiment,
|
|
34
|
+
ExperimentCLI,
|
|
35
|
+
Manifest,
|
|
36
|
+
yaml_parameter,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
from .experiment import RUN_ROOT, Experiment
|
|
40
|
+
from .rig import DEVICES, PolPlRig
|
|
41
|
+
|
|
42
|
+
#: The rig: sub-device name → the operations this experiment calls on its
|
|
43
|
+
#: server, as declared in the packaged ``config.yaml`` together with each
|
|
44
|
+
#: server's time limits. The preflight checks them before the first step, so a
|
|
45
|
+
#: server that is down or an address pointing at the wrong instrument is named
|
|
46
|
+
#: at start-up instead of failing a spectrum halfway through the sweep.
|
|
47
|
+
RIG: dict[str, list[str]] = {name: list(device["requires"]) for name, device in DEVICES.items()}
|
|
48
|
+
|
|
49
|
+
#: What this experiment takes, group by group, as ``config/default.yaml`` sets
|
|
50
|
+
#: it. A key whose value is a document rather than a scalar — the dot
|
|
51
|
+
#: catalogue, the open-ended fit options — is declared with
|
|
52
|
+
#: :func:`yaml_parameter`: no widget edits an arbitrary document, and a form
|
|
53
|
+
#: that quietly rejected what the experiment accepts would be worse than none.
|
|
54
|
+
SCHEMA: dict[str, list[ConfigParameter]] = {
|
|
55
|
+
"default": [
|
|
56
|
+
ConfigParameter(
|
|
57
|
+
"sample",
|
|
58
|
+
default="sample-000",
|
|
59
|
+
dtype=str,
|
|
60
|
+
description="Sample the dots are on; recorded with every spectrum.",
|
|
61
|
+
),
|
|
62
|
+
yaml_parameter(
|
|
63
|
+
"qds",
|
|
64
|
+
"The dots to measure, in order: id and stage coordinates in µm. Each is assumed "
|
|
65
|
+
"already on the spectrometer when its steps begin.",
|
|
66
|
+
[{"id": "QD-01", "x_um": 0.0, "y_um": 0.0}],
|
|
67
|
+
),
|
|
68
|
+
ConfigParameter(
|
|
69
|
+
"step_settle_s",
|
|
70
|
+
default=0.0,
|
|
71
|
+
dtype=float,
|
|
72
|
+
unit="s",
|
|
73
|
+
min_value=0.0,
|
|
74
|
+
description="Pause after the plate has settled, before each exposure.",
|
|
75
|
+
),
|
|
76
|
+
],
|
|
77
|
+
"hwp": [
|
|
78
|
+
ConfigParameter(
|
|
79
|
+
"start_deg",
|
|
80
|
+
default=0.0,
|
|
81
|
+
dtype=float,
|
|
82
|
+
unit="deg",
|
|
83
|
+
min_value=0.0,
|
|
84
|
+
max_value=360.0,
|
|
85
|
+
description="First half-wave-plate angle of the sweep.",
|
|
86
|
+
),
|
|
87
|
+
ConfigParameter(
|
|
88
|
+
"stop_deg",
|
|
89
|
+
default=178.0,
|
|
90
|
+
dtype=float,
|
|
91
|
+
unit="deg",
|
|
92
|
+
min_value=0.0,
|
|
93
|
+
max_value=360.0,
|
|
94
|
+
description=(
|
|
95
|
+
"Last angle, included when whole steps reach it. The plate's polarization "
|
|
96
|
+
"pattern repeats every 90°, so 0–178° covers two periods."
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
ConfigParameter(
|
|
100
|
+
"step_deg",
|
|
101
|
+
default=2.0,
|
|
102
|
+
dtype=float,
|
|
103
|
+
unit="deg",
|
|
104
|
+
min_value=0.01,
|
|
105
|
+
max_value=90.0,
|
|
106
|
+
description="Increment between angles; must be positive.",
|
|
107
|
+
),
|
|
108
|
+
],
|
|
109
|
+
"spectra": [
|
|
110
|
+
ConfigParameter(
|
|
111
|
+
"exposure_ms",
|
|
112
|
+
default=1000.0,
|
|
113
|
+
dtype=float,
|
|
114
|
+
unit="ms",
|
|
115
|
+
min_value=1.0,
|
|
116
|
+
description="Spectrometer exposure per frame.",
|
|
117
|
+
),
|
|
118
|
+
ConfigParameter(
|
|
119
|
+
"frames",
|
|
120
|
+
default=1,
|
|
121
|
+
dtype=int,
|
|
122
|
+
unit="",
|
|
123
|
+
min_value=1,
|
|
124
|
+
description="Frames acquired per angle; a stack is summed before the fit.",
|
|
125
|
+
),
|
|
126
|
+
ConfigParameter(
|
|
127
|
+
"filename_prefix",
|
|
128
|
+
default="pol_pl",
|
|
129
|
+
dtype=str,
|
|
130
|
+
description="Prefix of the spectrum files the spectrometer writes.",
|
|
131
|
+
),
|
|
132
|
+
ConfigParameter(
|
|
133
|
+
"file_visibility_timeout_s",
|
|
134
|
+
default=60.0,
|
|
135
|
+
dtype=float,
|
|
136
|
+
unit="s",
|
|
137
|
+
min_value=0.0,
|
|
138
|
+
description=(
|
|
139
|
+
"How long a written spectrum may take to appear on this machine's mount "
|
|
140
|
+
"before the step is treated as failed."
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
],
|
|
144
|
+
"analysis": [
|
|
145
|
+
ConfigParameter(
|
|
146
|
+
"enabled",
|
|
147
|
+
default=True,
|
|
148
|
+
dtype=bool,
|
|
149
|
+
description="Run the fine-structure analysis after each dot's sweep.",
|
|
150
|
+
),
|
|
151
|
+
ConfigParameter(
|
|
152
|
+
"rotator",
|
|
153
|
+
default="hwp",
|
|
154
|
+
dtype=str,
|
|
155
|
+
# From the analyzer's own table, not a copy of it: a list written
|
|
156
|
+
# out here would be a form offering fewer choices than the
|
|
157
|
+
# experiment accepts the moment plesty-fss gains one.
|
|
158
|
+
options=sorted(HARMONICS),
|
|
159
|
+
description=(
|
|
160
|
+
"Which rotator's angle the analysis fits against — a half-wave plate turns "
|
|
161
|
+
"the polarization twice as fast as a rotating polarizer."
|
|
162
|
+
),
|
|
163
|
+
),
|
|
164
|
+
yaml_parameter(
|
|
165
|
+
"fit_options",
|
|
166
|
+
"Passed to plesty.lib.analyzer.SpectrumFit — e.g. peaks: [775.1, 776.4], "
|
|
167
|
+
"window: 0.1, center_tolerance: 0.05. Empty: lines are found automatically.",
|
|
168
|
+
{},
|
|
169
|
+
),
|
|
170
|
+
],
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
MANIFEST = Manifest(
|
|
174
|
+
module="plesty.pol_pl",
|
|
175
|
+
title="Polarization PL",
|
|
176
|
+
schema=SCHEMA,
|
|
177
|
+
environment=[
|
|
178
|
+
EnvVar("HWP_ADDRESS", "ZMQ address of the rotation stage carrying the half-wave plate."),
|
|
179
|
+
EnvVar("SPEC_ADDRESS", "ZMQ address of the spectrometer server."),
|
|
180
|
+
EnvVar("PLESTY_DATA", "The shared data disk as this machine sees it."),
|
|
181
|
+
],
|
|
182
|
+
run_root=RUN_ROOT,
|
|
183
|
+
viz="plesty.pol_pl.viz",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
cli = ExperimentCLI(MANIFEST)
|
|
187
|
+
#: Sub-devices the run can do without — none: without the stage there is no
|
|
188
|
+
#: polarization sweep, without the spectrometer no spectrum.
|
|
189
|
+
cli.optional()
|
|
190
|
+
cli.add_argument(
|
|
191
|
+
"--prominence",
|
|
192
|
+
type=float,
|
|
193
|
+
default=None,
|
|
194
|
+
metavar="COUNTS",
|
|
195
|
+
sets="analysis.fit_options.prominence",
|
|
196
|
+
help=(
|
|
197
|
+
"Line-finding threshold: a line must stand at least this many counts above "
|
|
198
|
+
"its surroundings (default: analysis.fit_options.prominence of the configuration)."
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def with_prominence(config: dict[str, Any], prominence: Optional[float]) -> dict[str, Any]:
|
|
204
|
+
"""Return *config* with the line-finding threshold set, or unchanged for ``None``.
|
|
205
|
+
|
|
206
|
+
The threshold is ``analysis.fit_options.prominence``: a line must stand at
|
|
207
|
+
least that many counts above its surroundings to be found. The run saves
|
|
208
|
+
the configuration it used, so the value set here is on record.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
config: The loaded measurement configuration.
|
|
212
|
+
prominence: The threshold in counts, or ``None`` to leave it alone.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
The configuration to freeze into the plan.
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ValueError: If *prominence* is not a positive number of counts.
|
|
219
|
+
"""
|
|
220
|
+
if prominence is None:
|
|
221
|
+
return config
|
|
222
|
+
if prominence <= 0:
|
|
223
|
+
raise ValueError(f"prominence must be a positive number of counts, got {prominence}.")
|
|
224
|
+
updated = deepcopy(config)
|
|
225
|
+
analysis = updated.setdefault("analysis", {})
|
|
226
|
+
fit_options = dict(analysis.get("fit_options") or {})
|
|
227
|
+
fit_options["prominence"] = float(prominence)
|
|
228
|
+
analysis["fit_options"] = fit_options
|
|
229
|
+
return updated
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@cli.configure
|
|
233
|
+
def configure(args: Any, config: dict[str, Any]) -> dict[str, Any]:
|
|
234
|
+
"""Apply the command line's overrides to the loaded configuration.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
args: The parsed command line.
|
|
238
|
+
config: The configuration as read from the YAML file.
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
The configuration the run freezes into its plan.
|
|
242
|
+
"""
|
|
243
|
+
return with_prominence(config, args.prominence)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@cli.check
|
|
247
|
+
def check(args: Any, config: dict[str, Any]) -> None:
|
|
248
|
+
"""Refuse an incomplete configuration before anything connects.
|
|
249
|
+
|
|
250
|
+
What only a hardware run needs — the shared data disk — is checked here
|
|
251
|
+
rather than in the experiment, so ``--dry-run`` works without it and a
|
|
252
|
+
missing disk costs nothing measured.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
args: The parsed command line.
|
|
256
|
+
config: The configuration this run would use.
|
|
257
|
+
"""
|
|
258
|
+
planner = Experiment(config=config, run_root=cli.run_root(args))
|
|
259
|
+
Experiment.validate_hardware_config(planner.config, planner.data_root)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@cli.rig
|
|
263
|
+
def build_devices(args: Any, config: dict[str, Any], skip: frozenset[str]) -> PolPlRig:
|
|
264
|
+
"""Build the rig, leaving out the sub-devices named in *skip*.
|
|
265
|
+
|
|
266
|
+
Each sub-device's address is read from its own ``<NAME>_ADDRESS``
|
|
267
|
+
environment variable. Called again on every start-up retry, so an edited
|
|
268
|
+
``.env`` or a server started in the meantime is picked up.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
args: The parsed command line.
|
|
272
|
+
config: The measurement configuration.
|
|
273
|
+
skip: Optional sub-devices to build the rig without.
|
|
274
|
+
"""
|
|
275
|
+
chosen = {name: dict(device) for name, device in DEVICES.items() if name not in skip}
|
|
276
|
+
return PolPlRig(devices=chosen)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@cli.build
|
|
280
|
+
def build_experiment(
|
|
281
|
+
args: Any, config: dict[str, Any], run_root: Optional[str], devices: Any
|
|
282
|
+
) -> BaseExperiment:
|
|
283
|
+
"""Build the experiment on the rig.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
args: The parsed command line.
|
|
287
|
+
config: The measurement configuration, frozen into the run's plan.
|
|
288
|
+
run_root: Where this run's checkpoint directory goes.
|
|
289
|
+
devices: The connected rig, or ``None`` for ``--dry-run``.
|
|
290
|
+
"""
|
|
291
|
+
return Experiment(devices=devices, config=config, run_root=run_root)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
if __name__ == "__main__": # pragma: no cover
|
|
295
|
+
sys.exit(cli.main())
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# pol_pl module configuration — constants and running defaults.
|
|
2
|
+
# Loaded via plesty.pol_pl.load_config(); shipped inside the wheel.
|
|
3
|
+
# Credentials never live here (see .env.example); host-specific values are
|
|
4
|
+
# overridden via the environment or CLI arguments.
|
|
5
|
+
# Measurement run tuning lives in config/*.yaml at the repository root and is
|
|
6
|
+
# frozen into each run's plan — this file is for fixed module constants only.
|
|
7
|
+
|
|
8
|
+
# Fixed module constants: which instruments this rig is made of, and how long
|
|
9
|
+
# their operations are physically allowed to take. Properties of the hardware,
|
|
10
|
+
# not of a measurement, which is why they are not run configuration.
|
|
11
|
+
constants:
|
|
12
|
+
# The rig's sub-devices, written in the declarative form
|
|
13
|
+
# plesty.lib.device.composite_device.CompositeDevice takes verbatim: name →
|
|
14
|
+
# {address, env, timeout_ms, attempts, backoff_s}. No address appears here —
|
|
15
|
+
# each is read from <NAME>_ADDRESS in the environment (HWP_ADDRESS,
|
|
16
|
+
# SPEC_ADDRESS) by the platform convention, so a deployment is configured by
|
|
17
|
+
# its .env rather than by code or a tracked file.
|
|
18
|
+
#
|
|
19
|
+
# requires lists what each server must expose — the preflight turns "the
|
|
20
|
+
# port answers" into "it is the stage / the spectrometer". A wrong
|
|
21
|
+
# <NAME>_ADDRESS connects fine and would otherwise fail at the first angle.
|
|
22
|
+
devices:
|
|
23
|
+
hwp:
|
|
24
|
+
# Thorlabs K10CR1 rotation stage carrying the half-wave plate in front of
|
|
25
|
+
# the fixed polarizer (plesty-k10cr1). Routine calls are sub-second;
|
|
26
|
+
# moves and homing raise their own window per call (see waveplate).
|
|
27
|
+
timeout_ms: 5000
|
|
28
|
+
connect_deadline_s: 10
|
|
29
|
+
requires: [home_stage, move_absolute, get_position]
|
|
30
|
+
spec:
|
|
31
|
+
# Princeton Instruments LightField spectrometer
|
|
32
|
+
# (plesty-lightfield-spectrometer). Its socket is bound before LightField
|
|
33
|
+
# has started, so a connect during that start-up is answered late rather
|
|
34
|
+
# than refused — tens of seconds later. Waiting is what works; it costs
|
|
35
|
+
# nothing when the server is already up.
|
|
36
|
+
timeout_ms: 10000
|
|
37
|
+
connect_deadline_s: 90
|
|
38
|
+
requires: [write, query, acquire, get_recent_file, set_data_path]
|
|
39
|
+
|
|
40
|
+
# Operation budgets, in seconds — how long the physics may take.
|
|
41
|
+
waveplate:
|
|
42
|
+
# Must exceed the K10CR1 server's own budget — the firmware move timeout
|
|
43
|
+
# (60 s) plus its settle confirmation (plesty-k10cr1#2, up to another 60 s
|
|
44
|
+
# for a stage that never comes to rest) — so the stage errors before the
|
|
45
|
+
# client does.
|
|
46
|
+
move_timeout_s: 130.0
|
|
47
|
+
# A homing seek can traverse the full revolution at the slow homing speed.
|
|
48
|
+
home_timeout_s: 180.0
|
|
49
|
+
# Fast window for the small steps of a sweep (a 2° move takes ~1 s).
|
|
50
|
+
small_move_timeout_s: 5.0
|
|
51
|
+
# Moves beyond this, or from an unknown position, use the full budget.
|
|
52
|
+
small_step_deg: 10.0
|
|
53
|
+
# The stage reports its position only once it has settled, so the reported
|
|
54
|
+
# angle is checked against the target: a mismatch beyond this is a failed
|
|
55
|
+
# positioning, not noise. K10CR1 resolution is 26 µdeg.
|
|
56
|
+
position_tolerance_deg: 0.01
|
|
57
|
+
|
|
58
|
+
spectrometer:
|
|
59
|
+
# Slack added to exposure × frames for the acquire call: arming, readout,
|
|
60
|
+
# the .spe write to the network share, and the first file-generation setup.
|
|
61
|
+
acquire_grace_s: 120.0
|
|
62
|
+
# A settings write is sub-second; this covers a busy LightField.
|
|
63
|
+
write_timeout_s: 30.0
|
|
64
|
+
|
|
65
|
+
# Runtime defaults, overridable per host via the environment or CLI.
|
|
66
|
+
running:
|
|
67
|
+
timeout: 5
|