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.
@@ -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