plesty-lib 0.2.8__tar.gz → 0.2.9__tar.gz

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 (112) hide show
  1. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/.gitlab-ci.yml +1 -1
  2. plesty_lib-0.2.9/CHANGELOG.md +42 -0
  3. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/PKG-INFO +1 -1
  4. plesty_lib-0.2.9/docs/api_reference.md +19 -0
  5. plesty_lib-0.2.9/docs/contributing.md +28 -0
  6. plesty_lib-0.2.9/docs/data_schemas.md +89 -0
  7. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/docs/device/base_device.md +45 -13
  8. plesty_lib-0.2.9/docs/device/composite_device.md +60 -0
  9. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/docs/device/index.md +5 -0
  10. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/docs/device/logging_system.md +5 -6
  11. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/docs/device/test_helper.md +89 -0
  12. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/docs/device/traffic_manager.md +1 -1
  13. plesty_lib-0.2.9/docs/experiment.md +119 -0
  14. plesty_lib-0.2.9/docs/imgs/device_layers.svg +77 -0
  15. plesty_lib-0.2.9/docs/imgs/device_standard.svg +58 -0
  16. plesty_lib-0.2.9/docs/imgs/plesty_framework.svg +66 -0
  17. plesty_lib-0.2.9/docs/index.md +113 -0
  18. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/docs/quickstart.md +3 -4
  19. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/docs/toc.yaml +5 -2
  20. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/gl-secret-detection-report.json +4 -4
  21. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/data/__init__.py +3 -1
  22. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/data/io.py +137 -26
  23. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/device/__init__.py +5 -0
  24. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/device/base_device_sync.py +63 -8
  25. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/device/base_tcp_scpi_device.py +2 -2
  26. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/device/base_visa_scpi_device.py +2 -2
  27. plesty_lib-0.2.9/plesty/lib/device/composite_device.py +194 -0
  28. plesty_lib-0.2.9/plesty/lib/device/doc.py +37 -0
  29. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/device/funcs.py +255 -3
  30. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/device/params.py +14 -0
  31. plesty_lib-0.2.9/plesty/lib/device/telemetry.py +357 -0
  32. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/service/__init__.py +13 -28
  33. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/service/resource_manager.py +16 -21
  34. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/test/experiment_pipeline.py +11 -6
  35. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/utils/registry.py +9 -2
  36. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/pyproject.toml +3 -1
  37. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_device_base.py +136 -0
  38. plesty_lib-0.2.9/tests/test_device_telemetry.py +230 -0
  39. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_experiment.py +38 -1
  40. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/uv.lock +96 -120
  41. plesty_lib-0.2.8/CHANGELOG.md +0 -27
  42. plesty_lib-0.2.8/docs/api_reference.md +0 -16
  43. plesty_lib-0.2.8/docs/contributing.md +0 -0
  44. plesty_lib-0.2.8/docs/imgs/device_layers.png +0 -0
  45. plesty_lib-0.2.8/docs/imgs/device_standard.png +0 -0
  46. plesty_lib-0.2.8/docs/imgs/plesty_framework.png +0 -0
  47. plesty_lib-0.2.8/docs/index.md +0 -34
  48. plesty_lib-0.2.8/plesty/lib/device/composite_device.py +0 -63
  49. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/.gitignore +0 -0
  50. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/LICENSE +0 -0
  51. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/LICENSES/LGPL-3.0-or-later.txt +0 -0
  52. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/README.md +0 -0
  53. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/REUSE.toml +0 -0
  54. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/docs/device/cmd_solver.md +0 -0
  55. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/docs/device/error_handling.md +0 -0
  56. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/docs/device/func_system.md +0 -0
  57. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/docs/device/param_system.md +0 -0
  58. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/__init__.py +0 -0
  59. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/data/array.py +0 -0
  60. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/data/ctype_manager.py +0 -0
  61. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/data/table.py +0 -0
  62. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/data/types.py +0 -0
  63. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/data/units.py +0 -0
  64. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/device/async_wrapper.py +0 -0
  65. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/device/device_utils.py +0 -0
  66. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/experiment/__init__.py +0 -0
  67. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/experiment/base_experiment.py +0 -0
  68. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/experiment/journal.py +0 -0
  69. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/experiment/schedule.py +0 -0
  70. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/service/tcp_ip_client.py +0 -0
  71. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/service/tcp_ip_server.py +0 -0
  72. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/sim/__init__.py +0 -0
  73. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/sim/data_generator.py +0 -0
  74. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/solver/__init__.py +0 -0
  75. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/solver/iceblock.py +0 -0
  76. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/solver/scpi.py +0 -0
  77. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/test/__init__.py +0 -0
  78. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/test/device_func_system.py +0 -0
  79. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/test/device_param_system.py +0 -0
  80. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/test/device_pipeline.py +0 -0
  81. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/test/grouped_param_keys.py +0 -0
  82. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/test/resource_allocation.py +0 -0
  83. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/test/schema_params.py +0 -0
  84. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/traffic/__init__.py +0 -0
  85. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/traffic/serial.py +0 -0
  86. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/traffic/tcp_ip.py +0 -0
  87. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/traffic/usb_utils.py +0 -0
  88. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/traffic/utils.py +0 -0
  89. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/traffic/visa.py +0 -0
  90. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/utils/__init__.py +0 -0
  91. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/utils/dll_utils.py +0 -0
  92. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/utils/error_utils.py +0 -0
  93. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/utils/logger.py +0 -0
  94. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plesty/lib/utils/settings.py +0 -0
  95. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/plestylib/__init__.py +0 -0
  96. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_data_array.py +0 -0
  97. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_data_types.py +0 -0
  98. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_device_funcs.py +0 -0
  99. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_device_params.py +0 -0
  100. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_device_pipeline.py +0 -0
  101. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_device_scpi.py +0 -0
  102. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_experiment_pipeline.py +0 -0
  103. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_misc.py +0 -0
  104. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_schema_params.py +0 -0
  105. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_service_manager.py +0 -0
  106. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_settings.py +0 -0
  107. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_sim.py +0 -0
  108. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_solver.py +0 -0
  109. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_tcp_ip_resources.py +0 -0
  110. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_traffic_drivers.py +0 -0
  111. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_traffic_utils.py +0 -0
  112. {plesty_lib-0.2.8 → plesty_lib-0.2.9}/tests/test_utils.py +0 -0
@@ -17,7 +17,7 @@ stages:
17
17
  - release
18
18
 
19
19
  include:
20
- - component: $CI_SERVER_FQDN/plesty/plesty-ci/plesty-standard-ci@exp
20
+ - component: $CI_SERVER_FQDN/plesty/plesty-ci/plesty-standard-ci@main
21
21
  inputs:
22
22
  standard: quantum
23
23
  extra_branch: exp
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ ## 0.2.9 — 2026-07-24
4
+
5
+ - update CI component reference to branch `main`.
6
+ - Pin `[tool.plesty] standard = "quantum"` explicitly: core modules always check at quantum regardless of release version, now that the SDK derives the default standard from the version when unset (sdk#19).
7
+ - Documentation overhaul: the landing page now presents the whole library (devices → data → experiments) instead of devices only; new dedicated guides for the experiment framework (`docs/experiment.md` — plans, journaling, resume, `ExperimentPipeline` contract gates) and `CompositeDevice` (`docs/device/composite_device.md` — resilient remote calls); `doc_model()` documented in the base-device guide and `experiment_pipeline` in the test helpers; the three architecture figures are redrawn as SVGs in the PLESTY palette (the synchronized-device figure now includes the telemetry system); fixed the install instructions (`plesty-lib[dev]` is not an extra) and filled the empty contributing page. The generated API reference now documents `plesty.lib` itself (the deprecated `plestylib` shim is excluded via the SDK's new `api-ignore` setting) and sits as the last item of the navigation; several docstrings were fixed for the zero-warning docs build (RST-safe SCPI `*IDN?`/`*OPC?` mentions, resource-tree examples as literal blocks, experiment-pipeline gate summary as a list).
8
+ - Add a structured, render-agnostic device documentation model (sdk#18): `BaseDeviceSyncModel.doc_model()` returns a `DeviceDocModel` with (1) `parameters` grouped by config group (from the parameter system), (2) `functions` — the device's own operations as unified `FuncDoc` objects, combining schema-registered operations (`source="schema"`) and the device's `@expose_to_api` methods (`source="custom"`), and (3) `standard_methods` — the common device API every device shares (`connect`/`write`/`query`/`reset`/…, `source="standard"`). Backed by new `ConfigSystem.parameter_docs()` and `FunctionSystem.function_docs()`/`standard_method_docs()`; `summary()` is unchanged (renderers can migrate to `doc_model()` later). Framework plumbing is kept out of `functions` and surfaced under `standard_methods`.
9
+ - Enrich `doc_model()` method documentation (sdk#18): `_method_doc` now parses Google-style docstring `Args:`/`Returns:` sections so per-parameter and return descriptions populate `FuncDoc` (previously empty for `@expose_to_api` and standard methods), and `*args`/`**kwargs` are excluded from the documented parameter list. `BaseDeviceSyncModel.write`/`query` gained proper `Args:`/`Returns:` docstrings so the standard methods document their `key`/`value` parameters.
10
+ - Acquire the per-device resource lock on connect, not construction (sdk#18): `ResourceRegistry.acquire()` moved from `BaseDeviceSyncModel.__init__` to `__enter__` (released in `__exit__`, and on a failed connect). Constructing a device is now a side-effect-free operation — two instances of the same device id can coexist and only conflict when both connect — which lets documentation generators and offline tooling instantiate a device without holding hardware locks. `ResourceRegistry.acquire()` now also reclaims a key whose previous holder was garbage-collected (a dead weak reference no longer counts as in-use).
11
+
12
+ - `CompositeDevice` gains resilient remote calls (lib#15): `call(dev, func, ...)` invokes a sub-device method and, on a `TimeoutError`, rebuilds that client (flushing the stale ZMQ reply left in the socket) and retries before the final timeout propagates. A new `reconnect(dev)` and the module-level `connect_client(address, ...)` (build-with-connection-handshake-retry) back it; `__init__` accepts an optional `addresses` mapping so remote sub-devices are reconnectable. Every multi-device composite (e.g. hub scanners) gets this for free instead of re-implementing it.
13
+ - `FunctionSystem` now logs every registered-operation execution at INFO level, symmetric with the `ConfigSystem` write log: `[<device-id>] Running operation <name>(<param>=<value>, ...)`. Parameter values are rendered with a size-bounded repr so array-valued arguments do not flood the log.
14
+ - Add `plesty.lib.device.telemetry` — the unified operational-telemetry schema (lib#4): `DeviceStatus` (pull-style point-in-time snapshot) and `TelemetryEvent` (push-style timestamped record of kind `reading`/`state_change`/`error`/`warning`), plus crash-tolerant JSONL persistence helpers (`append_events`/`read_events`, same format as the experiment journal). The `TelemetrySystem` mixin composes into `BaseDeviceSyncModel` alongside `ConfigSystem`/`FunctionSystem`: every device gets consumer-registered event hooks, `emit_telemetry()`, and a free `status()` snapshot assembled from the connection check and cached parameter values (no device I/O); `write`/`query` emit `state_change`/`reading` events automatically when a hook is registered. No polling loops, threads, or transports — consumers own the event lifecycle.
15
+ - Formalise the experimental-output metadata schema (lib#4): the JSON document written by `save_result` is now the typed `ResultDocument` model (`plesty.lib.data.io`) with validated result types and an automatic write-time stamp; the on-disk layout is unchanged. New `load_document()` reads a result's metadata without any blob I/O; `load_result` routes through the model.
16
+ - Document all three data/metadata schema domains (device configuration, operational telemetry, experimental output) in `docs/data_schemas.md`.
17
+
18
+ ## 0.2.8 — 2026-07-07
19
+
20
+ - Add `plesty.lib.test.experiment_pipeline.ExperimentPipeline` (lib#14) — the experiment-tier counterpart of `DevicePipeline`: five hardware-free contract gates (public `Experiment` subclass instantiable without devices; deterministic `build_plan()` by content hash; every `Step.op` resolves via the framework's own plan validation; `Plan.save`/`load` round-trip preserves the hash; `setup`/`teardown` overrides are async) plus a one-shot `run_mock_pipeline()`. Experiment HUB modules expose one pytest function per gate; SDK gate E1 (plesty-sdk#15) verifies their presence and passage, mirroring gate d1.
21
+
22
+ - `Experiment.run` now validates the plan before anything executes: every `Step.op` must name an existing public callable on the experiment and may not be a reserved lifecycle method (`build_plan`, `run`, `setup`, `teardown`) — violations raise the new `InvalidOperationError` listing all offending steps, before any device is touched or journal written. The class docstring now spells out the override contract (`build_plan` required; `setup`/`teardown` optional extensions).
23
+
24
+ - Add `plesty.lib.experiment` — async `Experiment` ABC with atomic scheduling, status journaling and checkpoint/resume (first step toward lib#3): `Plan`/`Step` define the frozen measurement schedule (content-hashed so a resumed run refuses a changed schedule); `Journal` is a crash-safe append-only JSONL status log replayed to derive run state and the completed-step set; `Experiment` provides the async `setup()`/`run()`/`teardown()` lifecycle with per-step retries, clean handling of cancel/error, and resume via `run(resume=<run_id>)` that skips journaled-complete steps.
25
+ - Add `plesty.lib.data.io` (first step toward lib#4): `save_result`/`load_result` persist measurement results crash-safely — blobs stay in their original raw format (`.npy` for `PlestyArray`, native encoding for image bytes) described by a JSON metadata document (Plesty meta, provenance, timestamp) with a reference link to the blob; `convert_to_hdf5` optionally packs such documents into a unified HDF5 archive (requires the optional `h5py` dependency).
26
+
27
+ - Surface `op_schema`-registered functions through the `describe` endpoint (lib#13): `DeviceTCPIPServer._describe` now merges the `FunctionSystem._functions` registry into the method listing. These ops are installed as instance attributes and were therefore invisible to the MRO-based `@expose_to_api` walk, so remote clients could not discover or bind them (though they were already callable via the generic `call` path). `DeviceTCPIPClient` now binds them as local proxy methods.
28
+ - Migrate licensing to the [REUSE](https://reuse.software/) standard (lib#12, follows plesty-sdk#10): Python sources carry inline SPDX headers with a collective `SPDX-FileCopyrightText` (Plesty Development Team) plus a `SPDX-FileContributor` per author; non-code files are covered by a central `REUSE.toml`. The root `LICENSE` is kept for forge (GitLab) license detection; `COPYING`, `spdx.tmpl` and the unused `GPL-3.0-or-later.txt` were removed. `plesty check` Gate 9 now verifies `reuse lint` and authorship currency.
29
+
30
+ ## 0.2.7 — 2026-07-02
31
+
32
+ - Add `EnvSettings` in `plesty.lib.utils.settings`: a dependency-free helper that loads environment variables (optionally seeded from a `.env` file, process env taking precedence) with typed `get`/`require` access. By default `load()` also registers parsed `.env` values into `os.environ` without overriding existing ones (dotenv-style; pass `export=False` to disable). Generic by design — device modules choose their own variable names and defaults for credential/connection info (lib#11, companion to plesty-sdk#13)
33
+ - Expose `identity`, `check_errors`, `check_operatability`, `reset`, `clear` to the remote API via `@expose_to_api` on `BaseDeviceSyncModel`
34
+ - Fix `TCPIPTrafficManager._send_command`: pass actual `timeout` to `receive_one` instead of the boolean flag `timeout_overrided` (lib#9)
35
+ - Fix `sock_smart_read` JSON framing: exit the read loop when `terminator in msg` (consistent with line-data branch) and return `msg.rstrip()` — the previous `rstrip(terminator)` only stripped `\n`, leaving `\r` and causing the brace check to fail for `\r\n`-terminated responses (e.g. AMC300) (lib#9)
36
+ - Fix `DeviceTCPIPClient._send`: drain stale ZMQ reply after `recv()` timeout (100 ms window) so the client remains usable on the next call (lib#10)
37
+
38
+ ## 2026-06-25
39
+
40
+ - Add `FuncMeta` and `SCPISolver.solve_func` for schema-driven operation dispatch
41
+ - Fix mypy type annotations across all public modules
42
+ - Migrate CI pipeline to `plesty-standard-ci@exp`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plesty-lib
3
- Version: 0.2.8
3
+ Version: 0.2.9
4
4
  Summary: A library for the Plesty ecosystem.
5
5
  Author: Plesty Development Team
6
6
  Author-email: Yunshuang Yuan <yunshuang.yuan@fkp.uni-hannover.de>
@@ -0,0 +1,19 @@
1
+ # API Reference
2
+ Entry page for the [full `plesty.lib` API](reference/plesty/lib/index) documentation.
3
+
4
+ ## Core Packages
5
+
6
+ All packages live under the `plesty.lib` namespace (import as
7
+ `plesty.lib.<package>`).
8
+
9
+ | Package | Description |
10
+ | --- | --- |
11
+ | [`data`](reference/plesty/lib/data/index) | Data containers, units, type helpers, and result persistence
12
+ | [`device`](reference/plesty/lib/device/index) | Base device model, parameter/function/telemetry systems, async wrappers, composite devices |
13
+ | [`experiment`](reference/plesty/lib/experiment/index) | Async experiment lifecycle: `Experiment` ABC, `Plan`/`Step` schedules, crash-safe `Journal`, checkpoint/resume |
14
+ | [`traffic`](reference/plesty/lib/traffic/index) | Transport backends (TCP/IP, VISA, Serial) |
15
+ | [`solver`](reference/plesty/lib/solver/index) | Command and operation solvers |
16
+ | [`service`](reference/plesty/lib/service/index) | TCP/IP server/client and service builders |
17
+ | [`sim`](reference/plesty/lib/sim/index) | Synthetic data generation for testing |
18
+ | [`utils`](reference/plesty/lib/utils/index) | Shared utilities (env settings loader, logging, errors, registry, DLL helpers) |
19
+ | [`test`](reference/plesty/lib/test/index) | Test helpers |
@@ -0,0 +1,28 @@
1
+ # Contributing
2
+
3
+ PlestyLib is developed under the PLESTY governance standard. If you want to
4
+ contribute — a fix, a feature, or a whole new HUB module built on the
5
+ library — start with the
6
+ [PLESTY Contributor Guide](https://docs.plesty.net/contributor-guide/), which
7
+ covers the full workflow end to end.
8
+
9
+ The short version:
10
+
11
+ 1. **Branching** — work never lands on `main` directly. Branch from the
12
+ up-to-date `exp` branch (`git checkout exp && git pull && git checkout -b
13
+ <type>/<slug>`), implement, and merge back into `exp`.
14
+ 2. **Quality gates** — every change must pass `uv run plesty check` (the
15
+ `quantum` standard for core modules: formatting, typing, docstrings,
16
+ tests, licensing, and more). The pre-push hook installed by `plesty init`
17
+ runs it automatically.
18
+ 3. **Changelog** — add an entry under `## Unreleased` in `CHANGELOG.md` in
19
+ the same commit as your change.
20
+ 4. **Licensing** — the project follows the [REUSE](https://reuse.software/)
21
+ standard: Python sources carry SPDX headers with a
22
+ `SPDX-FileContributor` line per author; add yourself when you touch a
23
+ file.
24
+
25
+ Issues and merge requests live on
26
+ [gitlab.com/plesty/core/plesty-lib](https://gitlab.com/plesty/core/plesty-lib).
27
+ For questions about the module ecosystem, browse the searchable
28
+ [module registry](https://hub.plesty.net).
@@ -0,0 +1,89 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Plesty Development Team
3
+ SPDX-FileContributor: Yunshuang Yuan
4
+
5
+ SPDX-License-Identifier: LGPL-3.0-or-later
6
+ -->
7
+
8
+ # Data & Metadata Schemas
9
+
10
+ `plesty-lib` defines one schema contract per data domain so that every HUB
11
+ module acquires, stores, and exchanges data in the same shape. Three domains
12
+ are covered (issue lib#4):
13
+
14
+ | Domain | Schema | Module |
15
+ |---|---|---|
16
+ | Device configuration | `ConfigParameter` / `ConfigGroup` | `plesty.lib.device.params` |
17
+ | Operational telemetry | `DeviceStatus` / `TelemetryEvent` | `plesty.lib.device.telemetry` |
18
+ | Experimental output | `ResultDocument` | `plesty.lib.data.io` |
19
+
20
+ ## Device configuration
21
+
22
+ Structured representation of device parameters and settings — see
23
+ [Parameter System](device/param_system.md). Every parameter is a
24
+ `ConfigParameter` (name, dtype, unit, constraints, command binding) organised
25
+ into `ConfigGroup`s and managed by the `ConfigSystem` that every device model
26
+ composes. Device modules declare their parameters in a YAML/JSON schema which
27
+ `ConfigSystem` materialises at init.
28
+
29
+ ## Operational telemetry
30
+
31
+ Real-time status, state, and diagnostic data, defined in
32
+ `plesty.lib.device.telemetry`. Two shapes cover pull and push:
33
+
34
+ * **`DeviceStatus`** — a point-in-time snapshot. Every device inherits
35
+ `status()` via the `TelemetrySystem` mixin; the default implementation is
36
+ assembled from the connection check and the cached configuration values
37
+ (no device I/O), so it is always cheap to call:
38
+
39
+ ```python
40
+ status = device.status()
41
+ status.connection # "connected" | "disconnected" | "connecting" | "error"
42
+ status.state # "ready", "busy", ... (override device_state() to refine)
43
+ status.parameters # last-known parameter values
44
+ status.last_error # most recent error message, if any
45
+ ```
46
+
47
+ * **`TelemetryEvent`** — a timestamped record of kind `reading`,
48
+ `state_change`, `error`, or `warning`. Consumers register a hook; the base
49
+ device emits `state_change`/`reading` events automatically on every
50
+ successful `write`/`query`, and devices can emit custom events with
51
+ `emit_telemetry()`:
52
+
53
+ ```python
54
+ events = []
55
+ device.register_telemetry_hook(events.append)
56
+ device.write("wavelength", 850.0) # emits a state_change event
57
+
58
+ from plesty.lib.device.telemetry import append_events
59
+ append_events(run_dir / "telemetry.jsonl", events)
60
+ ```
61
+
62
+ Hooks run synchronously and never disturb device operation (a failing hook is
63
+ logged and skipped). When no hook is registered, no event is constructed.
64
+ `append_events`/`read_events` persist events in the same crash-tolerant
65
+ append-only JSONL format as the experiment journal. Polling loops, threads,
66
+ and transports are deliberately out of scope — they belong to monitors and
67
+ services, not to the schema.
68
+
69
+ ## Experimental output
70
+
71
+ Acquired data plus its metadata, defined in `plesty.lib.data.io`. The storage
72
+ contract keeps blobs in their **original raw format** (`.npy` for
73
+ `PlestyArray`, native encoding for image bytes) described by a JSON metadata
74
+ document — the typed `ResultDocument` model — holding the Plesty meta (name,
75
+ unit, range, description), timestamps, provenance, and a reference link to
76
+ the blob:
77
+
78
+ ```python
79
+ from plesty.lib.data import save_result, load_result, load_document
80
+
81
+ path = save_result(array, run_dir / "step_0001", provenance={"step_id": "s1"})
82
+ document = load_document(path) # metadata only — no blob I/O
83
+ result = load_result(path) # reconstructed PlestyArray
84
+ ```
85
+
86
+ The JSON document is the commit record: a result exists once its `.json`
87
+ file does, and writes are crash-safe (blob first, document last, both
88
+ atomic). `convert_to_hdf5` optionally packs a set of documents into a single
89
+ HDF5 archive for exchange (requires the optional `h5py` dependency).
@@ -19,7 +19,7 @@ When your device class inherits from `BaseDeviceSyncModel`, you get:
19
19
  3. Built-in high-level methods such as `write`, `query`, `state`, and `summary`.
20
20
  4. Context manager support (`with device:`) that calls `init`, `connect`, and `disconnect`.
21
21
  5. Resource tracking through `ResourceRegistry` to avoid duplicate ownership.
22
- 6. Per-instance resource locking (`_locked_resources`, `_clients`) for multi-client access control.
22
+ 6. Per-device resource management (`register_resources`, `allocate_resource`, …) for multi-client access control.
23
23
 
24
24
  ## Typical Layering
25
25
 
@@ -128,7 +128,8 @@ Useful built-in helpers:
128
128
 
129
129
  1. `state` / `get_state()`: query all registered parameters and return a dictionary.
130
130
  2. `synchronize_param_from_device(keys=None, sync_constraints=False)`: pull current values from hardware into the model.
131
- 3. `summary()`: generate a textual API overview.
131
+ 3. `summary(style=...)`: render a textual API overview (`short`, `md`, or `google`).
132
+ 4. `doc_model()`: return the structured documentation model (see below).
132
133
 
133
134
  If your device supports querying dynamic constraints, implement:
134
135
 
@@ -160,33 +161,64 @@ asyncio.run(main())
160
161
 
161
162
  ## Multi-Client Resource Control
162
163
 
163
- `BaseDeviceSyncModel` carries three resource-tracking attributes that support exclusive access
164
+ `BaseDeviceSyncModel` embeds resource tracking that supports exclusive access
164
165
  control when a device is shared by multiple clients (for example, over a TCP/IP service):
165
166
 
166
- | Attribute | Type | Purpose |
167
+ | Member | Type | Purpose |
167
168
  |---|---|---|
168
- | `_resources` | `list` | Hardware resources registered for automatic release on disconnect |
169
- | `_locked_resources` | `set` | Resources that are currently allocated to any client |
170
- | `_clients` | `dict` | Maps `client_id → [allocated resources]` |
169
+ | `_resources` | `list` | Inventory of physical resources this device owns; passed to the TCP server for per-client access control |
170
+ | `_resource_manager` | `ResourceManager \| None` | Embedded manager for direct (non-TCP) clients, created lazily by `register_resources()` |
171
171
 
172
- These attributes mirror the `ResourceManager` pattern from `plesty.lib.service.resource_manager`.
173
- You can compose a `ResourceManager` inside your device subclass and delegate to its methods, or
174
- override `_user_permission_check` to enforce access rules based on the per-instance state.
172
+ Four public methods delegate to the embedded `ResourceManager`
173
+ (`plesty.lib.service.resource_manager`):
174
+
175
+ 1. `register_resources(resources)`: declare the resources the device owns (flat list, set, or nested dict tree).
176
+ 2. `allocate_resource(resources, client_id)`: request exclusive access; returns `False` if any requested resource is already held by another client.
177
+ 3. `release_resource(client_id)`: free every resource held by that client.
178
+ 4. `client_resources(client_id)`: canonical resource paths currently allocated to that client.
175
179
 
176
180
  The default `_user_permission_check` always returns `True` (no restriction). Override it to
177
- implement real guard logic:
181
+ implement real guard logic — query current allocations via `client_resources` or the embedded
182
+ manager:
178
183
 
179
184
  ```python
180
185
  def _user_permission_check(self, user_resources: list, key: str) -> bool:
181
- if not user_resources:
186
+ if not user_resources or self._resource_manager is None:
182
187
  return True
183
- return any(r in self._locked_resources for r in user_resources)
188
+ locked = self._resource_manager._locked_resources
189
+ return any(r in locked for r in user_resources)
184
190
  ```
185
191
 
186
192
  Both `write` and `query` pass a `resources` kwarg through to `_user_permission_check` when the
187
193
  caller supplies it, so access control integrates transparently with the standard operation
188
194
  interface.
189
195
 
196
+ ## Documentation Model (`doc_model`)
197
+
198
+ Every device exposes a structured, render-agnostic documentation model —
199
+ the single source of truth for its user-facing documentation:
200
+
201
+ ```python
202
+ model = device.doc_model()
203
+ model.parameters # {group_name: [ConfigParameter, ...]} from the parameter system
204
+ model.functions # the device's own operations as unified FuncDoc objects
205
+ model.standard_methods # the common device API (connect/write/query/reset/...)
206
+ ```
207
+
208
+ `functions` unifies the two ways a device gains operations: schema-registered
209
+ operations (`source="schema"`) and `@expose_to_api` methods
210
+ (`source="custom"`); framework plumbing is kept out and surfaced separately
211
+ under `standard_methods` (`source="standard"`). Per-parameter and return
212
+ descriptions are parsed from Google-style docstring `Args:`/`Returns:`
213
+ sections — write those in your device methods and the generated documentation
214
+ inherits them.
215
+
216
+ Renderers consume the model instead of re-deriving metadata: the SDK docs
217
+ generator, `summary()`, and the server `describe` endpoint all build on it.
218
+ Because constructing a device is side-effect-free (the per-device resource
219
+ lock is only acquired on `connect`), documentation tooling can instantiate
220
+ your device without touching hardware.
221
+
190
222
  ## Best Practices
191
223
 
192
224
  1. Keep transport logic in traffic managers, not in device classes.
@@ -0,0 +1,60 @@
1
+ # Composite Device
2
+
3
+ `CompositeDevice` groups several devices — local instances and/or remote
4
+ TCP/IP clients — behind one object, so an orchestration (typically an
5
+ [Experiment](../experiment.md)) manages a whole setup as a single unit.
6
+
7
+ ## Building a composite
8
+
9
+ ```python
10
+ from plesty.lib.device.composite_device import CompositeDevice, connect_client
11
+
12
+ composite = CompositeDevice(
13
+ devices={
14
+ "stage": stage_device, # local BaseDeviceSyncModel
15
+ "cam": connect_client("tcp://192.168.1.20:5555"), # remote client
16
+ },
17
+ addresses={"cam": "tcp://192.168.1.20:5555"}, # remote devices that
18
+ ) # should be reconnectable
19
+ ```
20
+
21
+ Sub-devices are reached by name (`composite.stage`, `composite.cam`).
22
+ `connect_all()` / `disconnect_all()` handle every local sub-device, and the
23
+ composite is a context manager:
24
+
25
+ ```python
26
+ with composite:
27
+ print(composite.identity()) # {"stage": ..., "cam": ...}
28
+ ```
29
+
30
+ Remote clients manage their own connection; the `addresses` mapping is only
31
+ needed for remote sub-devices that should be *reconnectable* (below).
32
+
33
+ ## Resilient remote calls
34
+
35
+ Long-running orchestrations must survive transient network hiccups. Instead
36
+ of calling a remote sub-device method directly, route it through `call()`:
37
+
38
+ ```python
39
+ frame = composite.call("cam", "acquire", exposure_s=0.1)
40
+ ```
41
+
42
+ On a `TimeoutError`, `call()` rebuilds the sub-device's client — discarding
43
+ the stale ZMQ reply left in the socket, which would otherwise poison every
44
+ subsequent request — waits briefly, and retries (default 3 attempts) before
45
+ the final timeout propagates. `reconnect(dev)` performs the same rebuild
46
+ explicitly.
47
+
48
+ `connect_client(address)` is the module-level builder used for the rebuilds:
49
+ `build_client` plus a connection-handshake retry with linear backoff, which
50
+ matters when several clients open back-to-back against the same server.
51
+
52
+ Every multi-device composite gets this robustness for free instead of
53
+ re-implementing it per module.
54
+
55
+ ## Interplay with experiments
56
+
57
+ `Experiment` accepts a `CompositeDevice` at construction; its default
58
+ `setup()`/`teardown()` call `connect_all()`/`disconnect_all()`. Step
59
+ operations then drive sub-devices via `self.devices.<name>` or, for remote
60
+ calls that should retry, `self.devices.call(...)`.
@@ -11,6 +11,10 @@ If you are building a new device API, read in this order:
11
11
  7. [Logging System](logging_system.md)
12
12
  8. [Test Helpers](test_helper.md)
13
13
 
14
+ To orchestrate several devices as one unit (the shape an
15
+ [Experiment](../experiment.md) consumes), see
16
+ [Composite Device](composite_device.md).
17
+
14
18
  ## Topic Guide
15
19
 
16
20
  ### Core Architecture
@@ -18,6 +22,7 @@ If you are building a new device API, read in this order:
18
22
  1. [Base Device](base_device.md): Defines the synchronized device abstraction and lifecycle.
19
23
  2. [Traffic Manager](traffic_manager.md): Implements transport communication (Serial, VISA, TCP/IP).
20
24
  3. [Command Solver](cmd_solver.md): Maps standardized operations to protocol commands.
25
+ 4. [Composite Device](composite_device.md): Groups local and remote devices with resilient remote calls.
21
26
 
22
27
  ### Metadata and API Contracts
23
28
 
@@ -80,18 +80,17 @@ from plesty.lib.device.base_tcp_scpi_device import BaseTCPScpiDevice
80
80
 
81
81
 
82
82
  class DemoScpiDevice(BaseTCPScpiDevice):
83
- def __init__(self, host: str, port: int):
84
- super().__init__(host=host, port=port)
85
- self.register_config("POWER", dtype=float, read_only=True, command="MEAS:POW")
83
+ def __init__(self, host: str, port: int):
84
+ super().__init__(host=host, port=port)
85
+ self.register_config("POWER", dtype=float, read_only=True, command="MEAS:POW")
86
86
  logging.info("Initialization finished.")
87
-
88
- def some_utility_function():
87
+
88
+ def some_utility_function(self) -> None:
89
89
  try:
90
90
  # do something
91
91
  logging.debug("xxx done")
92
92
  except Exception as e:
93
93
  logging.error(f"Error: {e}")
94
-
95
94
  ```
96
95
 
97
96
  Why this helps developers:
@@ -13,6 +13,8 @@ and surface obvious breakage quickly.
13
13
  | `grouped_param_keys` | pytest fixtures that verify grouped parameter key resolution |
14
14
  | `resource_allocation` | Multiprocessing helpers for testing multi-client resource contention |
15
15
  | `schema_params` | Helpers for refreshing a JSON parameter schema from a real device |
16
+ | `device_pipeline` | Device-tier contract gates (SDK gate d1) |
17
+ | `experiment_pipeline` | Experiment-tier contract gates (SDK gate E1), hardware-free |
16
18
 
17
19
  ---
18
20
 
@@ -285,6 +287,93 @@ The function is marked `__test__ = False` so pytest does not auto-collect it.
285
287
 
286
288
  ---
287
289
 
290
+ ## 6. `device_pipeline` — Device Contract Gates
291
+
292
+ **Location:** `plesty.lib.test.device_pipeline`
293
+
294
+ The standard test pipeline for a PLESTY device API module (SDK gate d1). Construct a
295
+ `DevicePipeline` with the device class, its constructor arguments, and the schema paths,
296
+ then call the individual gate methods from pytest test functions — or `run_mock_pipeline()`
297
+ to run all mock gates in one shot.
298
+
299
+ ```python
300
+ # tests/test_pipeline.py of a device HUB module
301
+ from plesty.lib.test.device_pipeline import DevicePipeline
302
+
303
+ PIPELINE = DevicePipeline(
304
+ MyDevice,
305
+ "device-id",
306
+ param_schema="assets/param_schema.json",
307
+ op_schema="assets/op_schema.json",
308
+ )
309
+
310
+ def test_schema_integrity():
311
+ PIPELINE.test_schema_integrity()
312
+
313
+ def test_params_mock():
314
+ PIPELINE.test_params_mock()
315
+
316
+ # ... one pytest function per gate
317
+ ```
318
+
319
+ | Gate | Method | Checks |
320
+ |---|---|---|
321
+ | 1 | `test_schema_integrity` | `param_schema.json` / `op_schema.json` are well-formed (valid JSON, no duplicate keys, valid types) |
322
+ | 2 | `test_param_key_resolution` | every key in `get_config_list()` resolves via `get_config(key)` |
323
+ | 3 | `test_params_mock` | all config params round-trip through the mock solver |
324
+ | 4 | `test_funcs_mock` | all registered operations accept generated payloads |
325
+ | 5 | `test_lifecycle` | context manager completes; `is_operatable` is `True` after connect |
326
+ | 6 | `test_identity` | `identity()` returns a non-empty string |
327
+ | 7 | `test_check_errors` | `check_errors()` returns an empty list on a healthy mock device |
328
+ | 8 | `test_state_coverage` | `state` keys are a superset of `get_config_list()` |
329
+ | 9 | `test_resource_allocation` | multi-client resource contention is enforced (optional, spawns server + 3 clients) |
330
+ | 10 | `test_hardware_schema_refresh` | real hardware refreshes schema defaults/types (`@pytest.mark.hardware`, skipped in CI) |
331
+
332
+ ---
333
+
334
+ ## 7. `experiment_pipeline` — Experiment Contract Gates
335
+
336
+ The experiment-tier counterpart of `DevicePipeline`: five hardware-free gates
337
+ validating the plan/checkpoint contract of an
338
+ [Experiment](../experiment.md) subclass. The pipeline never calls `run()`,
339
+ so no instrument is needed.
340
+
341
+ ```python
342
+ # tests/test_pipeline.py of an experiment HUB module
343
+ from plesty.lib.test.experiment_pipeline import ExperimentPipeline
344
+
345
+ PIPELINE = ExperimentPipeline(MyExperiment)
346
+
347
+ def test_experiment_subclass():
348
+ PIPELINE.test_experiment_subclass()
349
+
350
+ def test_plan_deterministic():
351
+ PIPELINE.test_plan_deterministic()
352
+
353
+ def test_plan_ops_resolve():
354
+ PIPELINE.test_plan_ops_resolve()
355
+
356
+ def test_plan_serializable():
357
+ PIPELINE.test_plan_serializable()
358
+
359
+ def test_lifecycle_hooks():
360
+ PIPELINE.test_lifecycle_hooks()
361
+ ```
362
+
363
+ | Gate | Checks |
364
+ |---|---|
365
+ | 1 `test_experiment_subclass` | public `Experiment` subclass, instantiable without devices |
366
+ | 2 `test_plan_deterministic` | `build_plan()` twice yields the same `content_hash()` |
367
+ | 3 `test_plan_ops_resolve` | every `Step.op` resolves via the framework's plan validation |
368
+ | 4 `test_plan_serializable` | `Plan.save`/`load` round-trip preserves the hash |
369
+ | 5 `test_lifecycle_hooks` | `setup`/`teardown` overrides are async |
370
+
371
+ `run_mock_pipeline()` runs all gates in one shot. Experiment HUB modules
372
+ expose one pytest function per gate — SDK gate E1 verifies their presence
373
+ and passage, mirroring the device-tier gate d1.
374
+
375
+ ---
376
+
288
377
  ## Recommended Workflow
289
378
 
290
379
  1. Start with `device_param_system.auto_test(use_mock_solver=True)` after registering parameters —
@@ -42,7 +42,7 @@ Common built-in traffic managers include:
42
42
 
43
43
  1. `SerialTrafficManager` for serial ports.
44
44
  2. `VisaTrafficManager` for VISA resources.
45
- 3. `TcpIpTrafficManager` for TCP/IP connections.
45
+ 3. `TCPIPTrafficManager` for TCP/IP connections.
46
46
 
47
47
  ## Demo 1: Use the Built-in SerialTrafficManager
48
48
 
@@ -0,0 +1,119 @@
1
+ # Experiment Framework
2
+
3
+ `plesty.lib.experiment` turns a measurement routine into a **reproducible,
4
+ crash-tolerant run**. The mental model is checkpointed training in deep
5
+ learning: the *plan* is the training schedule, the *journal* is the training
6
+ log, and the completed-step set plus persisted step results form the
7
+ checkpoint.
8
+
9
+ An `Experiment` orchestrates devices through a
10
+ [`CompositeDevice`](device/composite_device.md) — it receives Device
11
+ instances, never raw hardware.
12
+
13
+ ## The pieces
14
+
15
+ | Piece | Role |
16
+ |---|---|
17
+ | `Step` | The smallest unit of work: a stable `id`, an operation name `op`, and its `params`. A step either completes (result persisted and journaled) or is re-run on resume. |
18
+ | `Plan` | The frozen, ordered schedule of steps plus the run configuration. Written to disk at run start, never mutated; a SHA-256 `content_hash()` identifies the schedule. |
19
+ | `Journal` | A crash-safe, append-only JSONL event log (`run_started`, `step_started`, `step_completed`, `step_failed`, `run_completed`/`_canceled`/`_aborted`). Replaying it derives the run state and the completed-step set. |
20
+ | `Experiment` | The async ABC tying it together: validate → journal → execute with retries → persist results → resume. |
21
+
22
+ Every run owns a directory:
23
+
24
+ ```text
25
+ <run_root>/<run_id>/
26
+ ├── plan.json # frozen schedule + config + content hash
27
+ ├── journal.jsonl # append-only event log
28
+ └── data/ # per-step results: raw blob + JSON metadata
29
+ ├── step_0000.json
30
+ └── step_0000.npy
31
+ ```
32
+
33
+ Step results are persisted with
34
+ [`save_result`](data_schemas.md) — the raw blob stays in its native format,
35
+ described by a typed `ResultDocument` with provenance (`step_id`, `op`,
36
+ `params`).
37
+
38
+ ## Writing an experiment
39
+
40
+ Subclasses override exactly three lifecycle hooks — `build_plan()`
41
+ (required), `setup()`/`teardown()` (optional, extend the defaults which
42
+ connect/disconnect all devices) — plus one public method per step operation:
43
+
44
+ ```python
45
+ import asyncio
46
+ from plesty.lib.data import PlestyArray
47
+ from plesty.lib.experiment import Experiment, Plan, Step
48
+
49
+ class LineScan(Experiment):
50
+ def build_plan(self) -> Plan:
51
+ steps = [
52
+ Step(id=f"scan[x={x}]", op="scan_point", params={"x": x})
53
+ for x in range(5)
54
+ ]
55
+ return Plan(steps, config={"points": 5})
56
+
57
+ def scan_point(self, x: int) -> PlestyArray:
58
+ # drive devices via self.devices, return the measurement
59
+ return PlestyArray([float(x)], name="signal", unit="a.u.")
60
+
61
+ run_id = asyncio.run(LineScan(run_root="runs").run())
62
+ ```
63
+
64
+ Rules that keep runs reproducible:
65
+
66
+ - **`build_plan()` must be deterministic** for a given configuration —
67
+ resume compares the regenerated plan against the persisted one by content
68
+ hash.
69
+ - **Step ids must be stable** (e.g. `"scan[x=3,y=5]"`) — never derived from
70
+ run time or randomness; resume matches completed steps by id.
71
+ - **Step operations are validated before anything runs**: every `Step.op`
72
+ must name an existing public method and may not be a reserved lifecycle
73
+ method (`build_plan`, `run`, `setup`, `teardown`). Violations raise
74
+ `InvalidOperationError` listing all offending steps — before any device is
75
+ touched or journal written.
76
+
77
+ Step methods may be sync or async; results are awaited automatically. Each
78
+ step is retried (`max_retries`, `retry_sleep` constructor arguments) before
79
+ the run aborts.
80
+
81
+ ## Crash, cancel, resume
82
+
83
+ `run()` returns the run id. If the run is interrupted — `Ctrl-C`, a crash,
84
+ an aborting step — pass the id back to continue:
85
+
86
+ ```python
87
+ run_id = asyncio.run(LineScan(run_root="runs").run())
88
+ # ... interrupted?
89
+ asyncio.run(LineScan(run_root="runs").run(resume=run_id))
90
+ ```
91
+
92
+ On resume the framework:
93
+
94
+ 1. regenerates the plan and **refuses to continue if its content hash
95
+ differs** from the stored one (`PlanMismatchError`) — a silently changed
96
+ schedule or configuration cannot corrupt a run;
97
+ 2. replays the journal to find completed steps and skips them;
98
+ 3. journals the resume (`run_started` with `resumed: true`) and continues.
99
+
100
+ `teardown()` is always awaited — on completion, failure, and cancellation —
101
+ so devices are released no matter how the run ends.
102
+
103
+ ## Contract tests without hardware
104
+
105
+ `plesty.lib.test.experiment_pipeline.ExperimentPipeline` verifies the
106
+ plan/checkpoint contract of an experiment module with **no instrument
107
+ attached** (it never calls `run()`). Five gates:
108
+
109
+ | Gate | Checks |
110
+ |---|---|
111
+ | 1 `test_experiment_subclass` | public `Experiment` subclass, instantiable without devices |
112
+ | 2 `test_plan_deterministic` | `build_plan()` twice → identical `content_hash()` |
113
+ | 3 `test_plan_ops_resolve` | every `Step.op` resolves via the framework's own plan validation |
114
+ | 4 `test_plan_serializable` | `Plan.save`/`load` round-trip preserves the hash |
115
+ | 5 `test_lifecycle_hooks` | `setup`/`teardown` overrides are async |
116
+
117
+ Experiment HUB modules expose one pytest function per gate (SDK gate E1
118
+ verifies their presence and passage — the experiment-tier mirror of device
119
+ gate d1); see [Test Helpers](device/test_helper.md) for the pattern.