diff --git a/AGENTS.md b/AGENTS.md index 54a0869..fa27e27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -195,7 +195,10 @@ BaseClient → ZMQ Network → ThreadPool + Broadcast ## Testing +Tests never use a fixed port. Use the `server_port` fixture; agents run the suite in parallel. + ### Test Setup (test/pytest/conftest.py) +- `server_port` fixture - Session-scoped pair of free consecutive ports (server port and broadcast port) - `start_server` fixture - Module-scoped server for all tests - `cli` fixture - New Client per test - `dummy_instrument` fixture - Test dummy instrument with submodules diff --git a/CONTEXT.md b/CONTEXT.md index 915941b..fc18479 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -20,15 +20,19 @@ A serializable description of an instrument, parameter, or method that lets clie _Avoid_: schema, spec **Broadcast**: -A parameter-change event published by the Server on its PUB socket for any subscriber. +A parameter-change event published by the Server on its PUB socket for any subscriber. Most Broadcasts are produced by the Server when it executes a client request; an instrument that implements the **Broadcaster** contract can also emit its own. _Avoid_: notification, event stream +**Broadcaster**: +The opt-in contract by which an instrument emits its own Broadcasts: it exposes `add_broadcast_sink` / `remove_broadcast_sink` / `broadcast`, and the Server registers itself as a sink when the instrument joins the Station. Instruments without it are untouched. The Parameter Manager is the first Broadcaster. It emits `pm-lock-update` (payload: a `PMLockBluePrint`, or `None` when its Lock was removed) and `pm-type-update` (payload: a `PMTypeBluePrint`, or `None` when the Type was removed), and re-emits the Server's own `parameter-creation` for the parameters it creates as side effects of Type edits, `add_instance` and Type Lock declarations; it emits no `parameter-deletion`. +_Avoid_: hook, callback, event emitter + **Virtual Instrument**: An instrument that lives entirely in the Server with no hardware behind it. _Avoid_: soft instrument, fake instrument (that's a dummy instrument, for testing) **Parameter Manager**: -The flagship Virtual Instrument: a hierarchical, persistent, profile-aware store of experiment parameters. +The flagship Virtual Instrument: a hierarchical, persistent, profile-aware store of experiment parameters. Its root owns the Type registry and all Lock and Type methods; its submodules are **Parameter Groups**. _Avoid_: param store, PM **Client Station**: @@ -49,6 +53,47 @@ A hardware-free test instrument shipped in `instrumentserver.testing` for develo A Server acting as a Client of another Server, so instruments can be re-exported downstream. _Avoid_: server-in-server, daisy-chaining (fine in prose, not as the term) +**Parameter Group**: +A submodule inside the Parameter Manager: a plain container of parameters and nested groups with no file, profile, Type or Lock logic of its own. Every submodule is a Parameter Group; only the root is the Parameter Manager, which extends the group with those responsibilities. +_Avoid_: nested manager, sub-manager + +**Type**: +A named set of relative parameter paths (each with a default value and unit), plus **Nested Types** required at named submodules. Type entries carry no value kind and no description. A Type is structural: it describes a shape, not a list of members. +_Avoid_: template, schema, class. Not to be confused with a parameter's **value kind** (numeric, string, bool…), which is the `ParameterTypes` enum in code. + +**Nested Type**: +A Type required at a named submodule of another Type, e.g. `qubit` requires a `readout` at its submodule `readout` (`add_nested_type("qubit", "readout", "readout")`). The outer Type's effective parameter set is its own entries plus every Nested Type's set under that submodule name. Nesting can go several levels deep but may not cycle. +_Avoid_: include, import, inherit, extend, subtype + +**Instance**: +A Parameter Manager submodule (at any depth, never the root and never under Globals) that carries every parameter path of a Type, including those of its Nested Types, each with the unit the Type declares. Values are irrelevant to matching. Membership is duck-typed and recomputed on demand, never stored. An empty Type has no Instances. A submodule may be an Instance of several Types at once. +_Avoid_: member, tagged submodule + +**Claiming Type**: +The Type whose tint a parameter row shows when several Types cover it: the innermost (most deeply nested) Type wins, then the largest. + +**Lock**: +A rule attached to one parameter (the **Follower**) naming another parameter, its **Target**. While the Lock is **locked**, the Follower answers `get` with the Target's value and refuses `set`. While **unlocked**, the Follower behaves as a plain parameter but remembers its Target so it can be locked again. Removing the Lock forgets the Target. Locks chain (a Target may itself have a Lock) but never cycle. Values are pulled on `get`; nothing is ever pushed into a Follower. +_Avoid_: link, binding, mirror, source. Not the server's **instrument mutex**. + +**Target**: +The parameter a Lock points at. A parameter that is the Target of at least one Lock is marked as such in the tree, its Followers counted locked and unlocked alike. Deleting a Target removes the Locks that pointed at it and clears the Type Lock rules whose stored Target it was; their Followers become plain parameters. +_Avoid_: source + +**Follower**: +A parameter that has a Lock. Convenience noun for prose; the code says "the parameter's lock". + +**Type Lock**: +A rule on a Type entry naming a Target. Declaring it puts an ordinary, locked Lock on that parameter in every current Instance, and every future Instance gets it at creation. Its default Target is `_globals..`, created on demand with the entry's default and unit. Instance parameters that already have a Lock on another Target are skipped with a warning. Removing the Type Lock removes only the rule; the Locks it created stay until removed individually. Instances that stop matching keep their Locks. +_Avoid_: group lock, rule (alone) + +**Globals**: +The reserved `_globals` submodule of the Parameter Manager that holds the default Targets of Type Locks. Its parameters are created on demand by a Type Lock or by a profile load that lists one, never through `add_parameter`, which refuses the name; otherwise a Globals parameter is ordinary: it can be read and set, and it is saved with the profile. Globals is never an Instance of anything. + +**Instrument mutex**: +The server's per-instrument `threading.RLock` that serialises concurrent `call`s to one instrument. Prose uses "instrument mutex" so it never collides with **Lock**; the code keeps its current names (`_instrument_locks`) with a rename note only. +_Avoid_: instrument lock (in prose) + ## Relationships - The **Server** owns instruments; **Clients** reach them only through **Proxy Instruments** built from **Blueprints** @@ -56,11 +101,13 @@ _Avoid_: server-in-server, daisy-chaining (fine in prose, not as the term) - A **Client Station** groups Proxy Instruments for one client; many Client Stations can share one Server - A **Virtual Instrument** is served like any other instrument; the **Parameter Manager** is one - **Chained Servers** compose: a downstream Server proxies an upstream Server's instruments +- A **Type** is a shape; an **Instance** is any submodule matching it. Adding a parameter to a Type writes it into every Instance; removing one from a Type leaves it on the Instances, which then show it as an untyped row +- A **Lock** makes a **Follower** read its **Target**'s value while locked; a **Type Lock** does this for every Instance of a Type, defaulting its Target to **Globals** ## Example dialogue > **Dev:** "If I set a value on a **Proxy Instrument**, who finds out?" -> **Domain expert:** "The **Server** executes the set under that instrument's lock, then emits a **Broadcast** — every subscribed GUI and **Listener** sees it, no polling needed." +> **Domain expert:** "The **Server** executes the set under that instrument's **instrument mutex**, then emits a **Broadcast** — every subscribed GUI and **Listener** sees it, no polling needed." > **Dev:** "And a **Client Station** is a second server?" > **Domain expert:** "No — it never owns instruments. It's a scoped view: one client's chosen set of **Proxy Instruments** over the same shared **Server**. If you actually need a second server re-exporting instruments, that's **Chained Servers**." @@ -69,3 +116,5 @@ _Avoid_: server-in-server, daisy-chaining (fine in prose, not as the term) - "apps" — the five console entry points are launchers, not five separate applications; they collapse into three features (Server, Client Station, Monitoring) plus two convenience launchers (Detached GUI, Parameter Manager GUI). Resolved: docs pages follow features, not entry points. - "sub-server" — used colloquially for **Client Station**; resolved: explanation, not terminology. - "virtual instrument" vs "dummy instrument" — distinct: virtual = production feature with no hardware; dummy = testing stand-in for hardware. +- "type" — overloaded: the `ParameterTypes` enum is a parameter's **value kind** (drives widget choice); the Parameter Manager's **Type** is a structural shape of a submodule. Resolved: **Type** is the structural concept; the enum is called "value kind" in prose. +- "lock" — overloaded: the server's per-instrument RLock vs the Parameter Manager's user-facing **Lock**. Resolved: the RLock is the **instrument mutex** in prose; code names unchanged. diff --git a/docs/adr/0001-duck-typed-parameter-manager-types.md b/docs/adr/0001-duck-typed-parameter-manager-types.md new file mode 100644 index 0000000..4b87424 --- /dev/null +++ b/docs/adr/0001-duck-typed-parameter-manager-types.md @@ -0,0 +1,20 @@ +--- +status: accepted +date: 2026-09-16 +--- + +# Parameter Manager Types are structural (duck-typed), never a stored membership + +A Type in the Parameter Manager is a named shape: a set of relative parameter paths with units, plus Nested Types at named submodules. A submodule is an Instance of a Type purely because it carries every path of that shape with the declared unit; nothing records "q01 is a qubit". Membership is recomputed on demand and never persisted. + +## Considered options + +- **Explicit registry**: tag submodules with a Type and enforce structure on tagged ones. Rejected: it adds membership state to persist and migrate, and it makes "parameter removed from the Type but still on the Instance" a special case instead of the natural outcome (the row is simply untyped now). +- **Structural, computed** (chosen): matches how the lab thinks about it ("everything shaped like a qubit is a qubit"), needs no membership state, and lets one submodule match several Types (innermost, then largest, claims the tint). + +## Consequences + +- Deleting a required parameter silently makes a submodule stop matching; that is accepted. Deleting a parameter that is a Lock Target is the case that gets a GUI confirmation, not this one. +- An empty Type matches nothing, so a freshly created Type does not claim the tree. +- Matching walks the tree on every query. Trees are hundreds of parameters; no caching in the first version. +- The `_globals` submodule is excluded from matching at any depth. diff --git a/docs/adr/0002-pull-based-locks.md b/docs/adr/0002-pull-based-locks.md new file mode 100644 index 0000000..9708fce --- /dev/null +++ b/docs/adr/0002-pull-based-locks.md @@ -0,0 +1,21 @@ +--- +status: accepted +date: 2026-09-16 +--- + +# Parameter Locks pull on `get`; nothing is ever pushed into a Follower + +A locked Follower answers `get` by asking its Target and returning that value; `set` on a locked Follower raises. The Target knows nothing about its Followers, no value is ever copied into a Follower, and "who follows X" is computed by scanning when asked. The behaviour lives in a `Parameter` subclass (`ManagedParameter`) so the server's call path, the instrument mutex and the existing broadcasts are untouched: the server resolves the dotted path and calls the parameter exactly as it does today. + +## Considered options + +- **Push on set**: a Target's `set` rewrites every Follower and each rewrite needs its own broadcast. Rejected: it fans one request into many writes, requires the instrument to report side effects to the server for every value change, and forces a dedup rule so Listeners do not log twice. +- **Pull on get** (chosen): minimal change, the broadcast path is unchanged for values, cycle checking is just a walk up the Target chain, and each hop of a chain is decided by that hop's own locked/unlocked state at read time. + +## Consequences + +- A Follower keeps its own underlying value while locked. Unlocking exposes that own value again (deliberate: "unlock and see its own value" is the point). Profiles store the own value plus the Lock. +- QCoDeS reads the cache, not `get`, for snapshots with `update=False`. `ManagedParameter` therefore reports the Target's value in its snapshot while locked, and the profile writer reads the own value explicitly. Otherwise measurement metadata would record stale values. +- GUIs repaint Followers when they receive a `parameter-update` for the Target; the Parameter Manager emits nothing for values. +- Deleting a Target removes the Locks pointing at it; their Followers become plain parameters. When the deleted Target was the stored Target of Type Locks, those rules are cleared with it. +- Targets are restricted to parameters inside the same Parameter Manager. diff --git a/docs/adr/0003-broadcaster-contract.md b/docs/adr/0003-broadcaster-contract.md new file mode 100644 index 0000000..9ef46e0 --- /dev/null +++ b/docs/adr/0003-broadcaster-contract.md @@ -0,0 +1,23 @@ +--- +status: accepted +date: 2026-09-16 +--- + +# Instruments emit their own Broadcasts through an opt-in `Broadcaster` contract + +The Server only broadcasts what it can see: parameter sets it executed, and calls whose method name is literally `add_parameter` or `remove_parameter`. Structural changes inside an instrument (a Type edited, a Lock declared, parameters created as a side effect of `add_instance`) were invisible to other clients. We add a small mixin, `Broadcaster`, with `add_broadcast_sink`, `remove_broadcast_sink` and `broadcast(blueprint)`. When an instrument joins the Station (creation over the wire, or loading from config at startup) the Server checks `hasattr(instrument, "add_broadcast_sink")` and registers its own broadcast function as a sink. Instruments without the mixin are registered exactly as before. + +## Considered options + +- **Hard-code more method names in the Server**, as `add_parameter` is today. Rejected: the Server accumulates one instrument's API. +- **Instrument declares a method-name to action mapping** that the Server consults after each call. Rejected: passive and coarse; cannot express "one message per created parameter" and still couples the Server to method names. +- **Server diffs state after each call.** Rejected: heavy, cannot express Type changes. +- **GUI polling.** Rejected: latency and traffic for nothing. +- **Opt-in emitter contract** (chosen): generic, three lines in the Server, no base-class change, usable by any future Virtual Instrument, and standalone use of the instrument (no sinks) is a no-op. + +## Consequences + +- Messages use the existing `ParameterBroadcastBluePrint`, same socket, same topic (instrument name), same wire format. Existing subscribers parse them unchanged. +- The Parameter Manager emits `pm-lock-update` (`PMLockBluePrint` payload, `None` when a Lock was removed), `pm-type-update` (`PMTypeBluePrint` payload, `None` when a Type was removed), and re-emits `parameter-creation` for the parameters its Type edits, `add_instance` and Type Lock declarations create as side effects. It emits no `parameter-deletion`: only a direct `remove_parameter` call is announced, by the Server; the parameter removals of a profile load and of `remove_all_parameters` re-emit no creation or deletion Broadcasts. Direct `add_parameter` / `remove_parameter` calls keep being announced by the Server, so nothing is announced twice. +- `broadcast` runs on the thread that called the instrument method, which for a client request is the worker thread holding the instrument mutex: the same place the Server's own broadcasts already run. +- The `_instrument_locks` code in the Server is not renamed or altered; a comment notes that prose calls it the "instrument mutex". diff --git a/docs/conf.py b/docs/conf.py index f3c0b9a..da7aa5d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -86,7 +86,7 @@ } templates_path = ['_templates'] -exclude_patterns = ['build', 'agents', 'README.md', 'Thumbs.db', '.DS_Store', '**.ipynb_checkpoints'] +exclude_patterns = ['build', 'agents', 'adr', 'README.md', 'Thumbs.db', '.DS_Store', '**.ipynb_checkpoints'] # -- Internationalization ---------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#internationalization diff --git a/docs/getting_started/overview.md b/docs/getting_started/overview.md index f1df070..95d0fcd 100644 --- a/docs/getting_started/overview.md +++ b/docs/getting_started/overview.md @@ -203,7 +203,7 @@ instruments: type: instrumentserver.params.ParameterManager initialize: True gui: - type: instrumentserver.gui.instruments.ParameterManagerGui + type: instrumentserver.gui.parameter_manager.ParameterManagerGui ``` ![parameter_manager_2](../_static/param_manager_2.png) diff --git a/docs/technical_guide/broadcasts.md b/docs/technical_guide/broadcasts.md index 4a6c58e..3b5c051 100644 --- a/docs/technical_guide/broadcasts.md +++ b/docs/technical_guide/broadcasts.md @@ -1,12 +1,341 @@ # Broadcasts -:::{admonition} 🚧 This page is planned, not yet written -:class: warning -It will be replaced with verified content as the documentation refactor progresses. -::: +When a Client sets or reads a parameter, the Server announces it as a +**Broadcast**: a small message on a PUB socket that any number of subscribers +receive at once, from the Server's own GUI to a **Listener** on another +machine. Nobody polls. The +[getting started overview](../getting_started/how_it_works.md) shows what +Broadcasts are for; this page is the deeper twin. It covers what exactly +triggers a Broadcast, what the two frames on the wire look like, how a Client +subscribes with `SubClient`, how the Server forwards Broadcasts to other +machines, and how an instrument emits its own Broadcasts through the +**Broadcaster** contract, with the Parameter Manager's Lock, Type and +side-effect actions as the worked example. -This page will cover: +The user-facing side of the Parameter Manager's Broadcasts is in the User +Guide's [Parameter Manager](../user_guide/parameter_manager.md) page, and +Blueprints in general are described in +[Blueprints and Proxies](blueprints_and_proxies.md). -- What triggers a Broadcast; the message format -- SubClient mechanics; how GUIs stay live -- External broadcast forwarding +## What triggers a Broadcast + +The Server emits every Broadcast from the worker thread that executed the +client request, while that worker holds the instrument's instrument mutex. +Requests for one instrument are serialized by that mutex, so the Broadcasts +about one instrument go out in the order the requests ran. + +Four things trigger a Broadcast. A parameter set emits `parameter-update`, +carrying the value that was set. A parameter read emits `parameter-call`, +carrying the value that was read. A client call to a method literally named +`add_parameter` emits `parameter-creation`, and one to `remove_parameter` +emits `parameter-deletion`; the message's `name` is the instrument name and +the call's arguments joined with dots, and a creation carries the +`initial_value` and `unit` keyword arguments the call received. Any other +method call emits nothing. + +The examples below run against a Client and the two instruments they use: + +```pycon +>>> from instrumentserver.client import Client +>>> cli = Client() +>>> dummy = cli.find_or_create_instrument( +... "dummy", +... "instrumentserver.testing.dummy_instruments.generic.DummyInstrumentWithSubmodule", +... ) +>>> pm = cli.find_or_create_instrument( +... "parameter_manager", "instrumentserver.params.ParameterManager" +... ) +``` + +Captured through a `SubClient` (the SubClient section shows the wiring), the +triggers look like this, in the order they were emitted: + +```pycon +>>> dummy.param0.set(0.5) +>>> received[0].action, received[0].name, received[0].value +('parameter-update', 'dummy.param0', 0.5) +>>> dummy.param0() +>>> received[1].action, received[1].value +('parameter-call', 0.5) +>>> pm.add_parameter("extra.gain", initial_value=3, unit="V") +>>> received[2].action, received[2].name, received[2].value, received[2].unit +('parameter-creation', 'parameter_manager.extra.gain', 3, 'V') +>>> pm.remove_parameter("extra.gain") +>>> received[3].action, received[3].name, received[3].value +('parameter-deletion', 'parameter_manager.extra.gain', None) +``` + +That is the whole list for a plain instrument. An instrument that implements +the Broadcaster contract emits its own Broadcasts on top, which is how the +Parameter Manager announces Locks, Types and the parameters it creates as +side effects. + +Every Broadcast carries one of six action strings. They are module constants +in `blueprints.py`, and their values are the wire contract that external +subscribers parse: + +| Constant | Action string | Emitted by | `value` carries | +| --- | --- | --- | --- | +| `PARAMETER_UPDATE` | `parameter-update` | the Server, on a parameter set | the new value | +| `PARAMETER_CALL` | `parameter-call` | the Server, on a parameter read | the value that was read | +| `PARAMETER_CREATION` | `parameter-creation` | the Server, on a client call to `add_parameter`; the Parameter Manager, for parameters it creates as side effects | the initial value (`None` when none was given) | +| `PARAMETER_DELETION` | `parameter-deletion` | the Server, on a client call to `remove_parameter` | nothing (`None`) | +| `PM_LOCK_UPDATE` | `pm-lock-update` | the Parameter Manager, one per affected Follower | the Follower's `PMLockBluePrint`, or `None` when its Lock was removed | +| `PM_TYPE_UPDATE` | `pm-type-update` | the Parameter Manager, one per edited Type | the Type's fresh `PMTypeBluePrint`, or `None` when the Type was removed | + +## The wire format + +The Server publishes Broadcasts on a PUB socket bound to `tcp://*` on the +request port plus one. A Server on the default port 5555 publishes on 5556. + +A Broadcast is a two-frame ZMQ message. Frame 1 is the topic: the instrument +name, which is the first dotted component of the message's `name`. Frame 2 is +the JSON of the message's `ParameterBroadcastBluePrint` dict. Here is one +real message as a subscriber received it, both frames: + +``` +frame 1: 'dummy' +frame 2: {"name": "dummy.param0", "action": "parameter-update", "value": "0.5", "unit": "", "_class_type": "ParameterBroadcastBluePrint"} +``` + +The topic is what lets subscribers filter per instrument: a SUB socket that +subscribes to `parameter_manager` receives the Parameter Manager's Broadcasts +and nothing else. The subscription is a plain prefix match, so subscribing to +`dummy` also receives the messages of an instrument named `dummy2`. One +Broadcast reaches every subscriber: two SUB sockets connected to the +Server's PUB socket each received the identical two frames. + +Frame 2 carries every scalar field of the Blueprint as a string, including +`_class_type`, which names the class the dict stands for. A `value` that is +itself a Blueprint travels as a nested dict with its own `_class_type`, as +the Parameter Manager payloads at the end of this page show. `decode` turns +the payload back into the dataclass: `json.loads`, then `deserialize_obj`, +which sees the `_class_type` key and rebuilds a `ParameterBroadcastBluePrint` +from the fields. Decoding the frame 2 payload from above (a subscriber holds +it as `payload`), the numeric strings come back as numbers: + +```pycon +>>> from instrumentserver.base import decode +>>> bp = decode(payload) +>>> type(bp).__name__ +'ParameterBroadcastBluePrint' +>>> bp.name, bp.action, bp.value, bp.unit +('dummy.param0', 'parameter-update', 0.5, '') +``` + +One PUB/SUB quirk to know: a subscriber that connects after a Broadcast was +published misses it. The subscription only counts from the moment it reached +the Server's socket. So subscribe before you trigger, and give a fresh +subscriber a moment to connect before you expect messages from it. The +shared helpers sleep briefly after connecting for exactly this reason. + +## SubClient + +`SubClient` (in `instrumentserver.client.proxy`) is the Qt consumer of +Broadcasts. It opens a SUB socket on the Broadcast port, subscribes to the +instruments you name, or to everything when you pass none, and emits every +received Broadcast as a `ParameterBroadcastBluePrint` on its `update` +signal: + +```python +sub = SubClient(instruments=["parameter_manager"]) # or instruments=None: all +thread = QtCore.QThread() +sub.moveToThread(thread) # the receive loop runs on its own QThread +thread.started.connect(sub.connect) # connect() runs the loop until stop() +sub.finished.connect(thread.quit) +sub.update.connect(on_broadcast) # every Broadcast, as a ParameterBroadcastBluePrint +thread.start() +``` + +That is the exact wiring the Parameter Manager GUI and the shared helpers +use. `stop()` ends the receive loop; stop the thread with it, as the GUI's +`stopListener` does: `sub.stop()`, then `thread.quit()`, then +`thread.wait()`. + +This is also how GUIs stay live without polling. The Parameter Manager GUI's +model receives every Broadcast of its instrument through a `SubClient` and +routes the two Parameter Manager actions straight into its client-side +state: a `pm-lock-update` payload replaces one Follower's Lock, and a +`pm-type-update` payload replaces that one Type, which carries the whole +fresh definition, so the GUI never fetches that Type or Lock again. The +Parameter Manager's actions section at the end of this page shows the +payloads; [the Parameter Manager](../user_guide/parameter_manager.md) page +shows the widgets this feeds. + +You do not need Qt to subscribe. A plain SUB loop is the whole recipe, and +the Listener is exactly that: + +```python +import zmq +from instrumentserver.base import recvMultipart + +context = zmq.Context.instance() +socket = context.socket(zmq.SUB) +socket.connect("tcp://localhost:5556") +socket.setsockopt_string(zmq.SUBSCRIBE, "") +while True: + topic, blueprint = recvMultipart(socket) + # blueprint is a ParameterBroadcastBluePrint, decoded for you + ... +``` + +The [Monitoring](../user_guide/monitoring.md) page covers the Listener and +where it can send what it receives. + +## External forwarding + +By default the Server publishes Broadcasts at `tcp://*` on the request port +plus one, so every machine that can reach the Server can subscribe there. +The station config can additionally name a second address to publish the +same Broadcasts to, in its `networking` section: + +```yaml +networking: + # Adds an address to broadcast parameter changes to, Example: "tcp://192.168.1.1:6000" + externalBroadcast: "tcp://192.168.1.1:6000" +``` + +With that in place the Server binds a second PUB socket on the given address +and sends every Broadcast to both sockets. The copy is the same two-frame +message as the original: a subscriber on the external address received +frames identical to the ones on the local port, and a `SubClient` pointed at +the external address received the Blueprint unchanged. Existing subscribers +need no change; the address is just a second door into the same stream. +That is what puts live UIs and Listeners on other machines: a dedicated +monitoring network or a fixed address of your choosing, with the same +stream on both. + +In code, the same setting is the `ipAddresses` dict: `startServer` accepts +`ipAddresses={"externalBroadcast": "tcp://127.0.0.1:6000"}` and binds the +second PUB socket on it. [The Server](../user_guide/server.md) page covers +the config file and where each entry lands. + +## The Broadcaster contract + +Everything above is the Server broadcasting what it can see: parameter sets +and reads, and calls to methods literally named `add_parameter` and +`remove_parameter`. Structural changes inside an instrument stayed +invisible to everyone else. The Broadcaster contract (ADR-0003) is the +opt-in way for an instrument to announce them itself. + +An instrument implements the contract by mixing in `Broadcaster` (in +`instrumentserver.base`), which brings three methods: + +- `add_broadcast_sink(fn)` registers `fn` to receive every Broadcast the + instrument emits. Sinks are stored in a plain list, so registering the same + sink twice means receiving everything twice, and removing it once leaves it + registered once. +- `remove_broadcast_sink(fn)` removes it again. +- `broadcast(bp)` sends one `ParameterBroadcastBluePrint` to every + registered sink. With no sinks registered it is a no-op, so an instrument + used standalone (no Server, no sinks) emits nothing and nothing raises. An + exception raised inside one sink is logged and the remaining sinks still + receive the Broadcast. + +The Server knows the contract by shape, not by base class: when an +instrument joins the Station, the Server checks for an +`add_broadcast_sink` attribute and registers its own +`StationServer._broadcastParameterChange` as a sink. This happens at the two +points where instruments enter the Station: created over the wire with +`find_or_create_instrument`, and loaded from the station config at startup. +Instruments without the mixin are registered exactly as before and stay +untouched. + +`broadcast` runs on the thread that called the instrument method. For a +client request that is the worker thread holding the instrument mutex, the +same place the Server's own Broadcasts run from. And the emissions use the +same socket, the same topic (the instrument name) and the same two-frame +wire format as the Server's own Broadcasts, so an existing subscriber parses +them without knowing or caring who produced them. + +The shipping example is the test instrument the verification of this page +uses, `instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument`. +Its emitting method is the whole pattern: + +```python +def emit_broadcast(self, value=1.0, unit="V", action="parameter-update"): + bp = ParameterBroadcastBluePrint( + name=f"{self.name}.param0", action=action, value=value, unit=unit + ) + self.broadcast(bp) + return bp +``` + +The Server registered itself as a sink when the instrument joined the +Station, so `broadcast` put the Blueprint on the Server's PUB socket, and a +`SubClient` received it like any other Broadcast: + +```pycon +>>> bcaster = cli.find_or_create_instrument( +... "bcaster", +... "instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument", +... ) +>>> bcaster.emit_broadcast(value=3.0, unit="V") +>>> received[0].name, received[0].action, received[0].value, received[0].unit +('bcaster.param0', 'parameter-update', 3.0, 'V') +``` + +## The Parameter Manager's actions + +The Parameter Manager is the first instrument built on the Broadcaster +contract. It emits `pm-lock-update` and `pm-type-update`, and re-emits +`parameter-creation` for parameters it creates as side effects. On the wire +its emissions are indistinguishable from the Server's own: same socket, same +topic, same two-frame format. What the actions mean to use is described in +the User Guide's [Parameter Manager](../user_guide/parameter_manager.md) +page, its Locks and Types sections in particular. + +Every Lock method that changes a Lock emits one `pm-lock-update` per +affected Follower (D10). The message's `name` is the Follower's full dotted +path, and its `value` is the Follower's `PMLockBluePrint`, or `None` when +its Lock was removed. So `lock`, `unlock`, `relock`, `toggle_lock` and +`remove_lock` each announce their one change, and `remove_parameter` on a +Target announces every Lock that deleting it drops, one `None` payload per +dropped Follower. The no-op paths emit nothing: unlocking an already +unlocked Lock is not news. Here is the payload of a `pm-lock-update` as it +crossed the wire, stringified fields and all: + +``` +{"name": "parameter_manager.show.follower", "action": "pm-lock-update", "value": {"target": "parameter_manager.show.target", "locked": "True", "_class_type": "PMLockBluePrint"}, "unit": "", "_class_type": "ParameterBroadcastBluePrint"} +``` + +Every Type-editing method emits one `pm-type-update` per affected Type +(D22), the two Type Lock methods included. `lock_type_parameter` on top +creates the Locks it declares, so it additionally emits one +`pm-lock-update` per applied Lock; `unlock_type_parameter` removes only the +rule. The message's `name` is the Type's full dotted name, +`.`, and its `value` is the Type's fresh +`PMTypeBluePrint` with its entries (defaults, units and Type Lock Targets), +its Nested Types and its effective set, or `None` when the Type was removed. +The payload carries the whole new definition, which is why a GUI can replace +that one Type locally and recompute its tints with no follow-up fetch: + +``` +{"name": "parameter_manager.display", "action": "pm-type-update", "value": {"name": "display", "parameters": {"gain": {"default": "12", "unit": "dB", "target": "None"}}, "nested": {}, "effective": {"gain": {"unit": "dB", "from_type": "display"}}, "_class_type": "PMTypeBluePrint"}, "unit": "", "_class_type": "ParameterBroadcastBluePrint"} +``` + +Parameters the Parameter Manager creates as side effects are announced as +`parameter-creation`, one per created parameter in creation order: an +`add_type_parameter` that writes an entry into every Instance lacking it, an +`add_nested_type` that writes the Nested Type's entries under its submodule +into every Instance of the outer Type lacking them, an `add_instance`, and +the Globals Target that a Type Lock with no explicit Target creates on +demand. Direct `add_parameter` and `remove_parameter` calls keep being +announced by the Server, so nothing is ever announced twice. + +The emissions of one method arrive in a fixed order: + +- `lock_type_parameter` with the default Target: first the + `parameter-creation` of the Globals parameter, then one `pm-lock-update` + per applied Lock, then the Type's `pm-type-update`. +- `add_nested_type`: first one `parameter-creation` per created parameter, + in creation order, then the `pm-lock-update`s of the Type Locks the new + Instances get, then one `pm-type-update` per affected Type, the edited + Type first. +- `add_instance`: first one `parameter-creation` per created parameter, in + creation order, then the `pm-lock-update`s of the Type Locks the new + Instance gets at creation. It edits no Type, so no `pm-type-update`. +- `remove_parameter` on a Target: first one `pm-lock-update` with `None` per + dropped Follower, then one `pm-type-update` per affected Type (the Type + Locks whose stored Target it was), then the `parameter-deletion` that the + Server emits once the call has returned. diff --git a/docs/user_guide/parameter_manager.md b/docs/user_guide/parameter_manager.md index 4f664ed..e7f5c6e 100644 --- a/docs/user_guide/parameter_manager.md +++ b/docs/user_guide/parameter_manager.md @@ -1,14 +1,887 @@ # Parameter Manager -:::{admonition} 🚧 This page is planned, not yet written -:class: warning -It will be replaced with verified content as the documentation refactor progresses. +The Parameter Manager is instrumentserver's flagship Virtual Instrument: a +hierarchical, persistent, profile-aware store of experiment parameters. It +lives entirely in the Server, has no hardware behind it, and exists to be the +single source of truth for the numbers of your experiment: which frequency +each qubit uses, which gain each amplifier runs at, which LO every receiver +shares. + +You get one onto a Server like any other instrument, through a +[Client](client.md): + +```pycon +>>> from instrumentserver.client import Client +>>> cli = Client() +>>> pm = cli.find_or_create_instrument( +... "parameter_manager", +... "instrumentserver.params.ParameterManager", +... ) +``` + +Everything the Parameter Manager can do works from Python and from its GUI +alike, and the two stay in sync live. This page walks through the pieces in +order: plain hierarchical parameters, Types, Locks, Type Locks and Globals, +profile files, the GUI, and a full measurement-script example at the end. +Each section is a fresh start: the snippets assume an empty Parameter +Manager, so the outputs are exactly the ones you will see. + +## Concept + +The Parameter Manager is a Virtual Instrument: an instrument the Server owns +that has no hardware behind it. Its parameters are ordinary QCoDeS parameters +on the Server, which is what makes it the single source of truth: + +- Every Client and every GUI reads the same values, because there is only one + copy, and it lives in the Server. A Proxy Instrument never keeps a + diverging local value. +- Every change is announced as a Broadcast, so GUIs and Listeners follow + along without polling. The wire format is described in + [Broadcasts](../technical_guide/broadcasts.md). + +Two Clients see the same values with no refresh in between: + +```pycon +>>> pm_a = Client().find_or_create_instrument( +... "parameter_manager", +... "instrumentserver.params.ParameterManager", +... ) +>>> pm_a.add_parameter("q01.IF", initial_value=10e6, unit="Hz") +>>> pm_b = Client().get_instrument("parameter_manager") +>>> pm_b.q01.IF() +10000000.0 +>>> pm_a.q01.IF.set(11e6) +>>> pm_b.q01.IF() +11000000.0 +``` + +`pm_b` never refreshed anything: the read asked the Server, and the Server +holds the one value. When the change happened, the Server also put a +`parameter-update` Broadcast on its PUB socket, which is what the Parameter +Manager GUI listens to. When the Parameter Manager itself creates or edits +something, for example a Lock or a Type, it announces that with its own +Broadcast; the Technical Guide page above lists them all. + +## Hierarchical parameters + +Parameters live at dotted paths relative to the Parameter Manager, such as +`q01.readout.IF`. Every level of the path is a Parameter Group, a plain +container of parameters and further Parameter Groups, created on demand the +first time a path needs it. Only the root is the Parameter Manager itself; +the groups hold no files, no Types and no Locks. + +```pycon +>>> pm.add_parameter("q01.readout.IF", initial_value=20e6, unit="Hz") +>>> pm.add_parameter("q01.power", initial_value=-10, unit="dBm") +>>> pm.list() +['q01.readout.IF', 'q01.power'] +>>> pm.has_param("q01.readout.IF") +True +>>> pm.has_param("q01.readout.bw") +False +``` + +Through the Proxy Instrument, the hierarchy is attribute access, and +parameters read and set like any QCoDeS parameter: + +```pycon +>>> pm.q01.readout.IF() +20000000.0 +>>> pm.q01.readout.IF.set(21e6) +>>> pm.q01.readout.IF() +21000000.0 +>>> pm.q01.readout.IF.unit +'Hz' +``` + +:::{note} +The Proxy reflects the Server-side tree as it was when the Proxy was built or +last refreshed. When someone else creates parameters (another Client, or a +method that creates parameters as a side effect such as `add_instance`), call +`pm.update()` to pick them up. ::: -This page will cover: +`remove_parameter` deletes a parameter and prunes the Parameter Groups it +empties: + +```pycon +>>> pm.remove_parameter("q01.readout.IF") +>>> pm.list() +['q01.power'] +``` + +To keep emptied Parameter Groups around instead, pass `cleanup=False` to +`remove_parameter` and sweep them up later with `remove_empty_submodules()`. + +## Types + +Lab devices repeat: six qubits on one chip, four delivery channels per +module, each copy carrying the same parameters. The Parameter Manager makes +the repetition explicit with a Type. A Type is a named shape: a set of +relative parameter paths, each with a default value and a unit, plus Nested +Types required at named submodules. + +A Parameter Group that carries the whole shape is an Instance of that Type. +Nothing stores that membership anywhere: an Instance is found by shape, on +every query, and never registered. So a Parameter Group becomes an Instance +the moment it carries the shape, and stops being one the moment it does not. + +```pycon +>>> pm.add_type("qubit") +>>> pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") +>>> pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") +>>> pm.instances_of("qubit") +[] +>>> pm.add_instance("qubit", "q01") +>>> pm.add_instance("qubit", "q02") +>>> pm.instances_of("qubit") +['q01', 'q02'] +>>> pm.update() +>>> pm.q01.IF() +10000000.0 +>>> pm.q01.octave_gain() +10 +``` + +A fresh Type has no Instances, because nothing carries its shape yet. +`add_instance` writes the shape into a Parameter Group: every missing entry +is created with the Type's default value and unit, and parameters that exist +at those paths already are kept as they are. + +The shape-finding works in the other direction too. `q03` below was never +registered anywhere; it simply carries the shape, so it counts: + +```pycon +>>> pm.add_parameter("q03.IF", initial_value=99e6, unit="Hz") +>>> pm.add_parameter("q03.octave_gain", initial_value=1, unit="dB") +>>> pm.instances_of("qubit") +['q01', 'q02', 'q03'] +>>> pm.types_of("q03.IF") +['qubit'] +``` + +The same cuts both ways: delete a parameter an Instance needs, and that +submodule stops matching, with no other change. `q02` above stops being an +Instance the moment `q02.IF` is removed. + +### Editing a Type + +Adding an entry to a Type writes it into every Instance that lacks it, with +the entry's default and unit: + +```pycon +>>> pm.add_type_parameter("qubit", "window", default=0.5, unit="s") +>>> pm.update() +>>> pm.q03.window() +0.5 +``` + +Changing the default touches nobody who exists; only parameters created +later start with it: + +```pycon +>>> pm.set_type_parameter_default("qubit", "window", 1.0) +>>> pm.q01.window() +0.5 +>>> pm.add_instance("qubit", "q04") +>>> pm.update() +>>> pm.q04.window() +1.0 +``` + +`set_type_parameter_unit` changes the entry's unit and propagates it to that +parameter in every Instance. The unit is part of the shape, so a parameter +with the wrong unit keeps its Parameter Group out of the Instance list. + +### Nested Types + +A Type can require another Type at one of its submodules. A `qubit` needs a +`readout`; the Nested Type expands under the submodule it is required at: + +```pycon +>>> pm.add_type("readout") +>>> pm.add_type_parameter("readout", "bw", default=20e6, unit="Hz") +>>> pm.add_nested_type("qubit", "readout", "readout") +>>> pm.update() +>>> pm.q01.readout.bw() +20000000.0 +``` + +What a Type requires in total is its effective set: its own entries plus +every Nested Type's entries under their submodule names. `get_type` returns +it together with the rest of the definition: + +```pycon +>>> qubit = pm.get_type("qubit") +>>> qubit.nested +{'readout': 'readout'} +>>> qubit.effective["readout.bw"] +{'unit': 'Hz', 'from_type': 'readout'} +``` + +When several Types cover one parameter, the innermost Instance wins, then +the one with the larger effective set. That Claiming Type is what the GUI +tints a row with: + +```pycon +>>> pm.types_of("q01.readout.bw") +['readout', 'qubit'] +>>> pm.instances_of("readout") +['q01.readout', 'q02.readout', 'q03.readout', 'q04.readout'] +``` + +### Removing from a Type + +Removing an entry, a Nested Type or a whole Type leaves every parameter +alone. The submodules keep what they carry; matching simply follows the +shape, which the removal changed: + +```pycon +>>> pm.remove_type_parameter("qubit", "window") +>>> pm.has_param("q01.window") +True +>>> pm.remove_nested_type("qubit", "readout") +>>> pm.has_param("q01.readout.bw") +True +>>> pm.remove_type("qubit") +>>> pm.list_types() +['readout'] +>>> pm.has_param("q01.IF") +True +``` + +A Type that is still required as a Nested Type refuses to be removed, naming +the Types that nest it. + +## Locks + +Shared settings want one authoritative value. A Lock ties a parameter (the +Follower) to another parameter (its Target). A Lock has three states: + +- no Lock: the parameter behaves as a plain parameter; +- a Lock that is present but unlocked: the parameter answers `get` with its + own value, but remembers its Target so it can be locked again; +- a Lock that is locked: the parameter answers `get` with the Target's value + and refuses `set`. + +```pycon +>>> pm.add_parameter("q01Data.IF", initial_value=10e6, unit="Hz") +>>> pm.add_parameter("q01.IF", initial_value=5e6, unit="Hz") +>>> pm.lock("q01.IF", "q01Data.IF") +>>> pm.get_lock("q01.IF") +PMLockBluePrint(target='parameter_manager.q01Data.IF', locked=True, _class_type='PMLockBluePrint') +``` + +While locked, the Follower pulls the Target's value on every `get`. Nothing +is ever pushed into the Follower, and the Target knows nothing about its +Followers: + +```pycon +>>> pm.q01.IF() +10000000.0 +>>> pm.q01Data.IF.set(11e6) +>>> pm.q01.IF() +11000000.0 +``` + +A locked Follower refuses `set` with an error naming the Target. Over the +connection, Server-side errors arrive as a generic `Exception` (the +[Python Client](client.md) page describes this), and the message carries the +pair: + +```pycon +>>> try: +... pm.q01.IF.set(12e6) +... except Exception as exc: +... message = str(exc) +>>> "parameter_manager.q01.IF is locked to parameter_manager.q01Data.IF" in message +True +``` + +Locking never touches the Follower's own value. Unlocking exposes it again, +and relocking goes back to the Target; `toggle_lock` switches between the +two states: + +```pycon +>>> pm.unlock("q01.IF") +>>> pm.q01.IF() +5000000.0 +>>> pm.relock("q01.IF") +>>> pm.q01.IF() +11000000.0 +>>> pm.toggle_lock("q01.IF") +>>> pm.get_lock("q01.IF").locked +False +>>> pm.toggle_lock("q01.IF") +>>> pm.get_lock("q01.IF").locked +True +``` + +`remove_lock` removes a Lock entirely and forgets the Target. +`list_locks` reports every Lock in the Parameter Manager, and +`followers_of` names the Followers of one parameter: + +```pycon +>>> pm.followers_of("q01Data.IF") +['q01.IF'] +>>> pm.list_locks() +{'q01.IF': PMLockBluePrint(target='parameter_manager.q01Data.IF', locked=True, _class_type='PMLockBluePrint')} +``` + +A Lock never points at its own parameter, and its Target lives in the same +Parameter Manager. + +### Chains and cycles + +A Target may itself have a Lock, and each hop reads according to its own +state at read time: + +```pycon +>>> pm.add_parameter("q02.IF", initial_value=0, unit="Hz") +>>> pm.lock("q02.IF", "q01.IF") +>>> pm.q02.IF() +11000000.0 +>>> pm.unlock("q01.IF") +>>> pm.q02.IF() +5000000.0 +``` + +`q02.IF` is still locked, but it reads through `q01.IF`, which now answers +with its own value. Relock `q01.IF`, and `q02.IF` follows the Target again. + +Locks may chain but never cycle. A Lock that would close a cycle is refused, +with the whole chain in the error: + +```pycon +>>> try: +... pm.lock("q01Data.IF", "q02.IF") +... except Exception as exc: +... cycle_message = str(exc) +>>> cycle_message +'cannot lock parameter_manager.q01Data.IF to parameter_manager.q02.IF: cycle in Lock targets: parameter_manager.q02.IF -> parameter_manager.q01.IF -> parameter_manager.q01Data.IF' +``` + +Deleting a parameter removes the Locks that pointed at it; its Followers +become plain parameters and answer `get` with their own values again. A +Follower of a survivor keeps its Lock: + +```pycon +>>> pm.remove_parameter("q01Data.IF") +>>> pm.get_lock("q01.IF") +None +>>> pm.q01.IF() +5000000.0 +>>> pm.get_lock("q02.IF") +PMLockBluePrint(target='parameter_manager.q01.IF', locked=True, _class_type='PMLockBluePrint') +>>> pm.remove_lock("q02.IF") +>>> pm.list_locks() +{} +``` + +Every Lock method that changes a Lock emits one `pm-lock-update` Broadcast +per affected Follower, which is how the GUI and any other listener stay +current; [Broadcasts](../technical_guide/broadcasts.md) shows the payload. + +## Type Locks and Globals + +One Lock ties two parameters together. Usually you want one value shared by +every Instance of a Type: one LO frequency for all qubits. A Type Lock +declares that on a Type entry, and every Instance follows: + +```pycon +>>> pm.add_type("qubit") +>>> pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") +>>> pm.add_instance("qubit", "q01") +>>> pm.add_instance("qubit", "q02") +>>> pm.update() +>>> pm.lock_type_parameter("qubit", "IF") +[] +``` + +With no Target given, the Target is the Globals parameter +`_globals..`, created on demand with the entry's default value +and unit. Every current Instance gets an ordinary, locked Lock on it: + +```pycon +>>> pm.has_param("_globals.qubit.IF") +True +>>> cli.call("parameter_manager.get", "_globals.qubit.IF") +10000000.0 +>>> pm.get_lock("q01.IF") +PMLockBluePrint(target='parameter_manager._globals.qubit.IF', locked=True, _class_type='PMLockBluePrint') +``` + +Setting the Globals parameter moves every Follower at once: + +```pycon +>>> cli.call("parameter_manager.set", "_globals.qubit.IF", 12e6) +>>> pm.q01.IF() +12000000.0 +>>> pm.q02.IF() +12000000.0 +``` + +:::{note} +Why `Client.call`? On a Proxy Instrument, `pm.get` and `pm.set` are QCoDeS' +own local shorthands, and they only take a plain parameter name: a dotted +path raises `KeyError`. Attribute access does reach the Globals submodule +once the Proxy knows it; a Proxy built before the Globals parameter existed +needs one `pm.update()` first. The Parameter Manager's own dotted `get` and +`set` run through `Client.call`, as above. +::: + +An explicit Target names any parameter of the same Parameter Manager +instead: + +```pycon +>>> pm.add_parameter("lo.frequency", initial_value=1e6, unit="Hz") +>>> pm.remove_lock("q02.IF") # let q02 join the new Target below +>>> pm.lock_type_parameter("qubit", "IF", target="lo.frequency") +['q01.IF'] +``` + +The return value names the Instance parameters that were skipped, and the +Parameter Manager logs a warning: `q01.IF` already carried a Lock on another +Target (the Globals parameter), and a Lock is never re-pointed behind its +holder's back. Everything else is locked to the new Target. Declaring the +same Type Lock again is how the GUI's lock all button re-applies it to +everyone. + +`unlock_type_parameter` removes only the rule. The Locks it created stay +until they are removed individually, and Instances created after the removal +get no Lock: + +```pycon +>>> pm.unlock_type_parameter("qubit", "IF") +>>> pm.get_type("qubit").parameters["IF"]["target"] is None +True +>>> pm.get_lock("q02.IF") is not None +True +>>> pm.add_instance("qubit", "q03") +>>> pm.get_lock("q03.IF") +None +``` + +Globals itself is never an Instance of anything, and its parameters are +created on demand by the Type Lock, not through `add_parameter`, which +refuses the name with "the Globals submodule name is reserved". Otherwise a +Globals parameter is ordinary: it shows up in `list()`, can be read and set, +and is saved with the profile. + +## Profiles and files + +A Parameter Manager saves itself as a JSON profile document in the working +directory of the Server process, so the state survives restarts and can be +switched per experiment. This section starts from two parameters and a Lock +between them, so the document below shows all three shapes: + +```pycon +>>> pm.add_parameter("lo.frequency", initial_value=5e9, unit="Hz") +>>> pm.add_parameter("q01.IF", initial_value=10e6, unit="Hz") +>>> pm.lock("q01.IF", "lo.frequency") +>>> pm.toFile() +``` + +The default file is `parameter_manager-parameter_manager.json`, and it holds +the version-2 document: + +```json +{ + "parameters": { + "parameter_manager.lo.frequency": { + "unit": "Hz", + "value": 5000000000.0 + }, + "parameter_manager.q01.IF": { + "lock": { + "locked": true, + "target": "parameter_manager.lo.frequency" + }, + "unit": "Hz", + "value": 10000000.0 + } + }, + "types": {}, + "version": 2 +} +``` + +Three things to read off that document: + +- Parameter keys are the full dotted paths with the instrument name in + front, the same form the Locks and the Type Lock Targets use. +- `value` is always the parameter's own value. A locked Follower saves its + own value plus its `lock`, never the Target's value, so unlocking exposes + what was saved. +- `lock` appears only on parameters that carry a Lock, in either state. The + `types` section holds the Type definitions, with each entry's default, + unit and Type Lock Target. + +`fromFile` loads a document back, restoring own values and Lock states: + +```pycon +>>> pm.lo.frequency.set(6e9) +>>> pm.fromFile() +>>> pm.lo.frequency() +5000000000.0 +>>> pm.get_lock("q01.IF") +PMLockBluePrint(target='parameter_manager.lo.frequency', locked=True, _class_type='PMLockBluePrint') +``` + +Loading removes parameters the document does not list; that is the +`deleteMissing` default of the reader underneath, and `fromFile` always +loads with that default today. The dictionary variant takes it explicitly, +so a document that omits one parameter shows the difference: + +```pycon +>>> pm.add_parameter("temp.extra", initial_value=1) +>>> document = pm.toParamDict() +>>> del document["parameters"]["parameter_manager.temp.extra"] +>>> pm.fromParamDict(document, deleteMissing=False) +>>> pm.has_param("temp.extra") +True +>>> pm.fromParamDict(document) +>>> pm.has_param("temp.extra") +False +``` + +Any file named `parameter_manager-.json` in the working directory +is a profile. `refresh_profiles` re-reads the directory, `list_profiles` +reports what it found, and `switch_to_profile` moves between them: it saves +the profile being left, clears every parameter, Type and Lock, then loads +the profile you name: + +```pycon +>>> pm.toFile(name="cooldown") +>>> sorted(pm.refresh_profiles()) +['parameter_manager-cooldown.json', 'parameter_manager-parameter_manager.json'] +>>> pm.unlock("q01.IF") +>>> pm.lo.frequency.set(8e9) +>>> pm.switch_to_profile("parameter_manager") +>>> pm.lo.frequency() +5000000000.0 +>>> pm.get_lock("q01.IF").locked +True +>>> pm.switch_to_profile("cooldown") +>>> pm.lo.frequency() +8000000000.0 +>>> pm.get_lock("q01.IF") +PMLockBluePrint(target='parameter_manager.lo.frequency', locked=False, _class_type='PMLockBluePrint') +``` + +A file without a top-level `version` key is the old flat map from before +Types and Locks existed. Write one (any file named +`parameter_manager-.json` works) and load it: + +```json +{ + "parameter_manager.old_target": {"unit": "Hz", "value": 5}, + "parameter_manager.old_param": {"unit": "M", "value": 123} +} +``` + +```pycon +>>> pm.add_parameter("old_target", initial_value=5, unit="Hz") +>>> pm.add_parameter("old_param", initial_value=1, unit="M") +>>> pm.lock("old_param", "old_target") +>>> pm.unlock("old_param") # present but unlocked, so the load can set its value +>>> pm.fromFile("parameter_manager-legacy.json") +>>> pm.old_param() +123 +>>> pm.list_locks() +{'old_param': PMLockBluePrint(target='parameter_manager.old_target', locked=False, _class_type='PMLockBluePrint')} +``` + +:::{note} +The legacy reader writes no Types and no Locks. Parameters it does not list +are removed as above, their Locks with them; a Lock whose parameters stay is +untouched, exactly as the load above leaves the unlocked `old_param` Lock. +Saving always writes the version-2 document, so one save over an old file +upgrades it. +::: + +## The GUI + +The Parameter Manager GUI is one window with two tabs, Parameters and Types. +You can run it standalone: + +```{prompt} bash +instrumentserver-param-manager --port 5555 +``` + +The launcher connects a Client to the Server on that port, creates the +Parameter Manager named `parameter_manager` if it does not exist yet, and +opens the window. `--name` chooses a different Parameter Manager. The Server +window itself opens the generic instrument widget for a Parameter Manager +unless the station config's `gui` entry names +`instrumentserver.gui.parameter_manager.ParameterManagerGui`, as the +`serverConfig.yml` in the repository does; then the Server window embeds the +same widget, and the launcher above is the sure way to get it. Configs that +still name the older path `instrumentserver.gui.instruments.ParameterManagerGui` +keep working. +[the Server](server.md) covers launching and the station config, and +[GUI features](gui_features.md) describes the `gui` entry and the patterns +shared by every instrument window: starring, trashing, filtering, and the +detachable tabs. + +### The Parameters tab + +The Parameters tab is the tree of parameters: name, unit, a value editor per +row, and the strip at the bottom to add a parameter. Three things are +specific to the Parameter Manager: + +- **Tints and gutter bands.** Every parameter of an Instance is tinted with + the colour of its Claiming Type, and the thin coloured band at the left + edge stacks one segment per Type covering the row, outermost first, up to + three. When a Client edits a Type, the tints follow live, because the + Parameter Manager emits a `pm-type-update` Broadcast. + +:::{admonition} 📸 SCREENSHOT NEEDED +:class: attention +The Parameters tab with two Types tinting their rows: the parameter tree +with `qubit` rows in one tint and `readout` rows in another, the coloured +gutter bands stacked at the left edge, the "locked to" column showing +`locked to _globals.qubit.IF` on the locked rows, and the lock button on one +of them. Light theme: +`docs/_static/user_guide/parameter_manager/tree_tints_light.png`, dark +theme: `docs/_static/user_guide/parameter_manager/tree_tints_dark.png`. +::: + +% +% ```{image} ../_static/user_guide/parameter_manager/tree_tints_light.png +% :class: only-light +% :alt: The Parameters tab: the parameter tree with Type tints and gutter bands +% ``` +% +% ```{image} ../_static/user_guide/parameter_manager/tree_tints_dark.png +% :class: only-dark +% :alt: The Parameters tab: the parameter tree with Type tints and gutter bands +% ``` + +- **The "locked to" column.** A Follower shows `locked to ` while + its Lock is locked and `unlocked · ` while unlocked; a parameter + that is a Target shows `target ×N` for its N Followers. The lock button on + the row, purple while locked, toggles the Lock. +- **The context menu.** Right-clicking a row offers "Lock to…" and "Unlock" + beside the usual actions. + +"Lock to…" arms the Target picker: a strip appears under the toolbar, naming +the Follower, with a line edit that completes over every parameter path, +ranked so that the same relative path on another Instance comes first, then +paths containing that relative path, then the rest, and a Cancel button. +Click a tree row or complete a path to pick the Target. If the Server +refuses, for a cycle for example, the strip shows the error. + +:::{admonition} 📸 SCREENSHOT NEEDED +:class: attention +The arm strip in action: the strip under the toolbar reading +"Target for q01.IF", the line edit showing a typed partial path with the +completion popup open over the candidate paths, and the Cancel button at +its right. Light theme: +`docs/_static/user_guide/parameter_manager/arm_strip_light.png`, dark +theme: `docs/_static/user_guide/parameter_manager/arm_strip_dark.png`. +::: + +% +% ```{image} ../_static/user_guide/parameter_manager/arm_strip_light.png +% :class: only-light +% :alt: The arm strip picking a Target under the toolbar +% ``` +% +% ```{image} ../_static/user_guide/parameter_manager/arm_strip_dark.png +% :class: only-dark +% :alt: The arm strip picking a Target under the toolbar +% ``` + +### The Locks panel + +The lock icon in the toolbar, or Ctrl+Shift+L, opens the Locks panel next to +the tree. It shows the same Locks from the Target side: one row per Target, +with each Target's Followers nested beneath it, recursively for chains. Type +Lock Targets come first and are labelled with their Type, as +`[type: qubit] _globals.qubit.IF`. + +Each row carries what it needs. A Target row has a value editor, and setting +it repaints every Follower watching. A locked Follower row shows its value +read-only, with a lock or relock toggle and a remove button. A Type Lock row +has lock all, which re-applies the Type Lock to every Instance, and remove +rule, which removes only the rule and leaves the Locks. "Lock selection +to…" arms the picker for the row currently selected in the tree. + +:::{admonition} 📸 SCREENSHOT NEEDED +:class: attention +The Locks panel open next to the tree: the Type Lock Target row labelled +`[type: qubit] _globals.qubit.IF` with its value editor, two Follower rows +nested beneath it with read-only values and their toggle and remove buttons, +and the "Lock selection to…" button with the selected-parameter label at the +bottom. Light theme: +`docs/_static/user_guide/parameter_manager/locks_panel_light.png`, dark +theme: `docs/_static/user_guide/parameter_manager/locks_panel_dark.png`. +::: + +% +% ```{image} ../_static/user_guide/parameter_manager/locks_panel_light.png +% :class: only-light +% :alt: The Locks panel with a Type Lock Target and its Followers +% ``` +% +% ```{image} ../_static/user_guide/parameter_manager/locks_panel_dark.png +% :class: only-dark +% :alt: The Locks panel with a Type Lock Target and its Followers +% ``` + +### The Types tab + +The Types tab (Ctrl+Shift+Y switches between the two tabs) keeps the +structure work in one place, in three panes: + +- Left: the list of Types, each with its number of Instances and parameters, + tinted in the Type's colour, and the "New type:" strip below. +- Top right: the selected Type's entries as a tree. An entry of the Type + itself has an editable default, a Remove button, and the Type Lock toggle + in the "locked to" column; while the entry is locked, the column also + shows a re-target button and the Target's path. Entries that come from a + Nested Type are read-only and say "defined by "; submodule rows show + which Type they require and can drop the requirement. The "Add to type" + and "Nested type" strips below add entries and Nested Type requirements. +- Bottom right: the Instances of the selected Type, each with its parameter + count and the other Types it also carries. "Show" jumps to the Parameters + tab and selects the Instance; the "New instance:" strip adds one. + +:::{admonition} 📸 SCREENSHOT NEEDED +:class: attention +The Types tab with a Type selected: the tinted type list on the left, the +entries tree in the top right pane with an editable default, a Type Lock +toggle and a "defined by readout" row, the two strips below it, and the +Instances pane in the bottom right with one Instance row and its Show +button. Light theme: +`docs/_static/user_guide/parameter_manager/types_tab_light.png`, dark +theme: `docs/_static/user_guide/parameter_manager/types_tab_dark.png`. +::: + +% +% ```{image} ../_static/user_guide/parameter_manager/types_tab_light.png +% :class: only-light +% :alt: The Types tab with its three panes +% ``` +% +% ```{image} ../_static/user_guide/parameter_manager/types_tab_dark.png +% :class: only-dark +% :alt: The Types tab with its three panes +% ``` + +### Deleting a Target + +Deleting a parameter that others follow is the one destructive action with a +safety net: the Parameter Manager names every Follower whose Lock will +vanish and asks for confirmation. Cancel leaves the Server untouched; a +parameter without Followers is deleted without asking. + +:::{admonition} 📸 SCREENSHOT NEEDED +:class: attention +The "Remove Target?" confirmation dialog for a parameter with two +Followers: the question text listing each Follower with its Lock state, +with Cancel as the default button. Light theme: +`docs/_static/user_guide/parameter_manager/delete_confirmation_light.png`, +dark theme: +`docs/_static/user_guide/parameter_manager/delete_confirmation_dark.png`. +::: + +% +% ```{image} ../_static/user_guide/parameter_manager/delete_confirmation_light.png +% :class: only-light +% :alt: The Remove Target confirmation dialog +% ``` +% +% ```{image} ../_static/user_guide/parameter_manager/delete_confirmation_dark.png +% :class: only-dark +% :alt: The Remove Target confirmation dialog +% ``` + +### Keyboard shortcuts + +The shortcuts below matter most in this window; the defaults come from the +GUI's shortcut registry, and [GUI features](gui_features.md) shows how to +rebind them. + +| Action | Default keys | What it does | +| --- | --- | --- | +| Delete parameter | `Ctrl+Backspace` | Delete the selected parameter | +| Lock to… | `Ctrl+L` | Lock the selected parameter to… (pick a Target) | +| Unlock | `Ctrl+U` | Unlock the selected parameter | +| Show the Locks panel | `Ctrl+Shift+L` | Show or hide the Locks panel | +| Switch tabs | `Ctrl+Shift+Y` | Switch between the Parameters and Types tabs | +| Add parameter | `Ctrl+N` | Jump cursor to the add parameter bar | +| Load parameters | `Ctrl+Shift+O` | Load parameters from JSON file | +| Save parameters | `Ctrl+Shift+S` | Save parameters to JSON file | +| Refresh all | `Ctrl+Shift+R` | Refresh all parameters from instrument | + +## Using it from measurement code + +A measurement script treats the Parameter Manager like any instrument: find +it or create it, then read and set through the Proxy Instrument. One-time +setup declares the shape, and a Type Lock shares one value across every +Instance: + +```pycon +>>> from instrumentserver.client import Client +>>> cli = Client() +>>> pm = cli.find_or_create_instrument( +... "parameter_manager", +... "instrumentserver.params.ParameterManager", +... ) +>>> pm.add_parameter("power", initial_value=-10, unit="dBm") +>>> pm.add_type("qubit") +>>> pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") +>>> pm.add_instance("qubit", "q0") +>>> pm.add_instance("qubit", "q1") +>>> pm.update() +>>> pm.lock_type_parameter("qubit", "IF") +[] +``` + +The sweep sets the Globals Target and reads the Followers; both qubits move +together, and any GUI watching follows live: + +```pycon +>>> for if_hz in (10e6, 11e6, 12e6): +... cli.call("parameter_manager.set", "_globals.qubit.IF", if_hz) +... print(pm.q0.IF(), pm.q1.IF()) +10000000.0 10000000.0 +11000000.0 11000000.0 +12000000.0 12000000.0 +``` + +When one qubit has to deviate, unlock it first: setting a locked Follower +raises, naming its Target. The Follower keeps its own value the whole time, +so unlocking exposes it, and `relock` rejoins the Target: + +```pycon +>>> try: +... pm.q0.IF.set(13e6) +... except Exception as exc: +... "is locked to" in str(exc) +True +>>> pm.unlock("q0.IF") +>>> pm.q0.IF.set(13e6) +>>> pm.q0.IF() +13000000.0 +>>> pm.q1.IF() +12000000.0 +>>> pm.relock("q0.IF") +>>> pm.q0.IF() +12000000.0 +``` + +At the end of the experiment, save the profile, and the next run starts +where this one ended: + +```pycon +>>> pm.toFile() +``` -- Concept: the flagship Virtual Instrument; single source of truth -- Hierarchical parameters: add / remove / nesting -- Persistence: JSON files; profiles (refresh / switch) -- The Parameter Manager GUI and the `instrumentserver-param-manager` launcher -- Using it from measurement code +The [Python Client](client.md) page covers the Client's connection lifecycle +and error handling. The profile files end up in the working directory of the +Server process, as Profiles and files above describes, so where they live +follows from where the Server runs; [the Server](server.md) page documents +the Server itself. diff --git a/docs/user_guide/serverConfig.yml b/docs/user_guide/serverConfig.yml index a444dd2..1bc3d6c 100644 --- a/docs/user_guide/serverConfig.yml +++ b/docs/user_guide/serverConfig.yml @@ -83,7 +83,7 @@ instruments: gui: # By having the parameter manager GUI as the gui type, we can have it directly in the server instead of on a separate window. - type: instrumentserver.gui.instruments.ParameterManagerGui + type: instrumentserver.gui.parameter_manager.ParameterManagerGui # GUI defaults: Class-based configuration that applies to all instances of a given instrument class # These are merged with instance-specific gui.kwargs configs (instance settings take precedence) diff --git a/src/instrumentserver/__init__.py b/src/instrumentserver/__init__.py index c8b9129..69a85ff 100644 --- a/src/instrumentserver/__init__.py +++ b/src/instrumentserver/__init__.py @@ -21,6 +21,9 @@ def getInstrumentserverPath(*subfolder: str) -> str: PARAMS_SCHEMA_PATH = os.path.join(getInstrumentserverPath("schemas"), "parameters.json") +PM_V2_SCHEMA_PATH = os.path.join( + getInstrumentserverPath("schemas"), "parameter_manager_v2.json" +) DEFAULT_PORT = 5555 diff --git a/src/instrumentserver/apps.py b/src/instrumentserver/apps.py index d705097..8922272 100644 --- a/src/instrumentserver/apps.py +++ b/src/instrumentserver/apps.py @@ -12,7 +12,7 @@ from .client.application import ClientStationGui from .config import loadConfig from .gui import widgetMainWindow -from .gui.instruments import ParameterManagerGui +from .gui.parameter_manager import ParameterManagerGui from .log import setupLogging from .server.application import startServerGuiApplication from .server.core import startServer @@ -125,7 +125,7 @@ def parameterManagerScript() -> None: description="Starting a parameter manager instrument GUI" ) parser.add_argument("--name", default="parameter_manager") - parser.add_argument("--port", default=5555) + parser.add_argument("--port", default=5555, type=int) args = parser.parse_args() app = QtWidgets.QApplication([]) @@ -142,7 +142,13 @@ def parameterManagerScript() -> None: pm.fromFile() pm.update() - _ = widgetMainWindow(ParameterManagerGui(pm), "Parameter Manager") + # The GUI's broadcast listener must follow the server's broadcast port + # (request port + 1); without it the GUI listens on the default port + # regardless of --port. + _ = widgetMainWindow( + ParameterManagerGui(pm, sub_port=args.port + 1, sub_host="localhost"), + "Parameter Manager", + ) app.exec_() diff --git a/src/instrumentserver/base.py b/src/instrumentserver/base.py index b5623ef..bb2d397 100644 --- a/src/instrumentserver/base.py +++ b/src/instrumentserver/base.py @@ -1,10 +1,11 @@ import json import logging +from collections.abc import Callable from typing import Any, Tuple, Union import zmq -from .blueprints import deserialize_obj, to_dict +from .blueprints import ParameterBroadcastBluePrint, deserialize_obj, to_dict logger = logging.getLogger(__name__) @@ -71,6 +72,71 @@ def sendBroadcast(socket: "zmq.Socket", name: str, message: Any) -> None: socket.send(encode(message).encode("utf-8")) +class Broadcaster: + """ + Mixin implementing the Broadcaster contract: an instrument that emits its + own Broadcasts. + + The instrument calls :meth:`broadcast` with a + :class:`~instrumentserver.blueprints.ParameterBroadcastBluePrint`; the + Server registers itself as a sink with :meth:`add_broadcast_sink` when + the instrument joins the Station. Used standalone (no sinks registered), + :meth:`broadcast` is a no-op. + + Sinks are stored in a plain list: adding the same sink twice makes it + receive every Broadcast twice. An exception raised inside one sink is + logged and does not stop the remaining sinks. + + The annotations of the public methods are strings on purpose: the client + builds proxy methods by exec-ing the blueprint's call signature string, + and a rendered class annotation would reference a name the exec'd source + cannot resolve. Keep new blueprint-carrying annotations quoted like these. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._broadcast_sinks: list[Callable[[ParameterBroadcastBluePrint], None]] = [] + + def add_broadcast_sink( + self, fn: "Callable[[ParameterBroadcastBluePrint], None]" + ) -> None: + """ + Register ``fn`` as a sink that receives every Broadcast. + + :param fn: callable taking a ParameterBroadcastBluePrint. + """ + self._broadcast_sinks.append(fn) + + def remove_broadcast_sink( + self, fn: "Callable[[ParameterBroadcastBluePrint], None]" + ) -> None: + """ + Remove a sink that was registered with :meth:`add_broadcast_sink`. + + Removing a sink that is not registered does nothing. + """ + if fn in self._broadcast_sinks: + self._broadcast_sinks.remove(fn) + + def broadcast(self, bp: "ParameterBroadcastBluePrint") -> None: + """ + Send a Broadcast to every registered sink. + + With no sinks registered this is a no-op. An exception raised inside + one sink is logged and the remaining sinks still receive the + Broadcast. + """ + for sink in list(self._broadcast_sinks): + try: + sink(bp) + except Exception: + logger.exception( + f"Exception in broadcast sink {sink} while broadcasting " + f"'{bp.name}' / '{bp.action}'; continuing with the " + "remaining sinks." + ) + + def recvMultipart(socket: "zmq.Socket") -> Tuple[str, Any]: """ Recieves the broadcast from a broadcast message. It should consist of 2 parts: diff --git a/src/instrumentserver/blueprints.py b/src/instrumentserver/blueprints.py index 7cbcd54..4b1a2bf 100644 --- a/src/instrumentserver/blueprints.py +++ b/src/instrumentserver/blueprints.py @@ -78,6 +78,16 @@ ParameterType = Union[Parameter, ParameterWithSetpoints] +# Action strings carried in ParameterBroadcastBluePrint.action. These are the +# exact strings on the wire; PM_LOCK_UPDATE and PM_TYPE_UPDATE are emitted by +# instruments implementing the Broadcaster contract. +PARAMETER_UPDATE = "parameter-update" +PARAMETER_CALL = "parameter-call" +PARAMETER_CREATION = "parameter-creation" +PARAMETER_DELETION = "parameter-deletion" +PM_LOCK_UPDATE = "pm-lock-update" +PM_TYPE_UPDATE = "pm-type-update" + @dataclass class ParameterBluePrint: @@ -355,11 +365,16 @@ def bluePrintFromInstrumentModule( @dataclass class ParameterBroadcastBluePrint: - """Blueprint to broadcast parameter changes.""" + """Blueprint to broadcast parameter changes. + + ``value`` carries whatever payload the action needs: a plain value for + the parameter actions and a :class:`PMLockBluePrint` for + ``pm-lock-update`` (``None`` when a Lock was removed). + """ name: str action: str - value: int | None = None + value: Any | None = None unit: str = "" _class_type: str = "ParameterBroadcastBluePrint" @@ -389,11 +404,55 @@ def toJson(self) -> Dict[str, Any]: return bluePrintToDict(self) +@dataclass +class PMLockBluePrint: + """Blueprint of a Lock of the Parameter Manager. + + Carries the full name of the Target the Follower's Lock points at and + whether the Lock is currently locked. Sent as the value of + ``pm-lock-update`` Broadcasts and returned by the Lock API. + """ + + target: str + locked: bool + _class_type: str = "PMLockBluePrint" + + def toJson(self) -> Dict[str, Any]: + return bluePrintToDict(self) + + +@dataclass +class PMTypeBluePrint: + """Blueprint of a Type of the Parameter Manager. + + ``parameters`` carries the Type's own entries as + ``{path: {default, unit, target}}``, where ``target`` is the Target of + the entry's Type Lock (``None`` when it has none). ``nested`` maps the + submodule name that requires a Nested Type to the nested Type's name. + ``effective`` is the computed effective parameter set: every own and + Nested Type entry path expanded under its submodule names, mapped to + the unit the entry declares and the Type that defines it + (``{path: {unit, from_type}}``). Returned by the Type API and sent as + the value of ``pm-type-update`` Broadcasts. + """ + + name: str + parameters: Dict[str, Dict[str, Any]] + nested: Dict[str, str] + effective: Dict[str, Dict[str, str]] + _class_type: str = "PMTypeBluePrint" + + def toJson(self) -> Dict[str, Any]: + return bluePrintToDict(self) + + BluePrintType = Union[ ParameterBluePrint, MethodBluePrint, InstrumentModuleBluePrint, ParameterBroadcastBluePrint, + PMLockBluePrint, + PMTypeBluePrint, ] @@ -862,6 +921,9 @@ def dict_to_serialized_dict( serialized_iterable = dict_to_serialized_dict(dct=value) converted_dict[name] = serialized_iterable + elif isinstance(value, get_args(BluePrintType)): + converted_dict[name] = bluePrintToDict(value) + # Enum/IntFlag members are treated as scalars. This must come before # the generic Iterable check: since Python 3.11 a Flag member is # iterable and a single-bit member iterates to itself, which would diff --git a/src/instrumentserver/client/application.py b/src/instrumentserver/client/application.py index 9c04582..df5d806 100644 --- a/src/instrumentserver/client/application.py +++ b/src/instrumentserver/client/application.py @@ -8,7 +8,7 @@ from qtpy.QtWidgets import QFileDialog, QWidget from instrumentserver import QtCore, QtGui, QtWidgets, getInstrumentserverPath -from instrumentserver.blueprints import ParameterBroadcastBluePrint +from instrumentserver.blueprints import PARAMETER_UPDATE, ParameterBroadcastBluePrint from instrumentserver.client import ClientStation from instrumentserver.client.proxy import SubClient from instrumentserver.gui.instruments import GenericInstrument @@ -184,7 +184,7 @@ def __init__(self, station: ClientStation): @QtCore.Slot(ParameterBroadcastBluePrint) def listenerEvent(self, message: ParameterBroadcastBluePrint) -> None: - if message.action == "parameter-update": + if message.action == PARAMETER_UPDATE: logger.info(f"{message.action}: {message.name}: {message.value}") def openInstrumentTab(self, item: QtWidgets.QTreeWidgetItem, index: int) -> None: diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 9ef0a47..64970f7 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -1,17 +1,31 @@ import inspect import logging -from typing import Any, Callable, Dict, Optional, Union, cast +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + List, + Optional, + Type, + Union, + cast, +) from qcodes import Instrument from instrumentserver.gui.misc import AlertLabelGreen from .. import DEFAULT_PORT, QtCore, QtGui, QtWidgets -from ..blueprints import ParameterBroadcastBluePrint +from ..blueprints import ( + PARAMETER_CALL, + PARAMETER_CREATION, + PARAMETER_DELETION, + PARAMETER_UPDATE, + ParameterBroadcastBluePrint, +) from ..client import ProxyInstrument, SubClient from ..helpers import nestedAttributeFromString -from ..params import ParameterManager, ParameterTypes, parameterTypes, paramTypeFromName -from . import keepSmallHorizontally from .base_instrument import ( DelegateBase, InstrumentDisplayBase, @@ -21,177 +35,30 @@ ) from .parameters import AnyInput, AnyInputForMethod, ParameterWidget +if TYPE_CHECKING: + from .parameter_manager import ParameterManagerGui, PMState + # TODO: all styles set through a global style sheet. # TODO: [maybe] add a column for information on valid input values? logger = logging.getLogger(__name__) +#: Names that moved to :mod:`instrumentserver.gui.parameter_manager` and are +#: still served from this module, so station configs that name +#: ``instrumentserver.gui.instruments.ParameterManagerGui`` keep loading. +_MOVED_TO_PARAMETER_MANAGER = ("ParameterManagerGui", "PMState") -class AddParameterWidget(QtWidgets.QWidget): - """A widget that allows parameter creation. - - :param parent: parent widget - :param typeInput: if ``True``, add input fields for creating a value - validator. - """ - - #: Signal(str, str, str, ParameterTypes, str) - newParamRequested = QtCore.Signal(str, str, str, ParameterTypes, str) - - #: Signal(str) - invalidParamRequested = QtCore.Signal(str) - - def __init__( - self, parent: Optional[QtWidgets.QWidget] = None, typeInput: bool = False - ) -> None: - super().__init__(parent) - self.typeInput = typeInput +def __getattr__(name: str) -> Union[Type["ParameterManagerGui"], Type["PMState"]]: + # imported on first use: the parameter_manager package imports this module + if name in _MOVED_TO_PARAMETER_MANAGER: + from . import parameter_manager - layout = QtWidgets.QGridLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - self.nameEdit = QtWidgets.QLineEdit(self) - lbl = QtWidgets.QLabel("Name:") - lbl.setAlignment( - cast( - "QtCore.Qt.Alignment", - QtCore.Qt.AlignmentFlag.AlignRight - | QtCore.Qt.AlignmentFlag.AlignVCenter, - ) - ) - layout.addWidget(lbl, 0, 0) - layout.addWidget(self.nameEdit, 0, 1) - - self.valueEdit = QtWidgets.QLineEdit(self) - lbl = QtWidgets.QLabel("Value:") - lbl.setAlignment( - cast( - "QtCore.Qt.Alignment", - QtCore.Qt.AlignmentFlag.AlignRight - | QtCore.Qt.AlignmentFlag.AlignVCenter, - ) - ) - layout.addWidget(lbl, 0, 2) - layout.addWidget(self.valueEdit, 0, 3) - - self.unitEdit = QtWidgets.QLineEdit(self) - lbl = QtWidgets.QLabel("Unit:") - lbl.setAlignment( - cast( - "QtCore.Qt.Alignment", - QtCore.Qt.AlignmentFlag.AlignRight - | QtCore.Qt.AlignmentFlag.AlignVCenter, - ) + return cast( + Union[Type["ParameterManagerGui"], Type["PMState"]], + getattr(parameter_manager, name), ) - layout.addWidget(lbl, 0, 4) - layout.addWidget(self.unitEdit, 0, 5) - - if typeInput: - self.typeSelect = QtWidgets.QComboBox(self) - names: list[str] = [] - for t, v in parameterTypes.items(): - names.append(str(v["name"])) - for n in sorted(names): - self.typeSelect.addItem(n) - self.typeSelect.setCurrentText( - str(parameterTypes[ParameterTypes.numeric]["name"]) - ) - lbl = QtWidgets.QLabel("Type:") - lbl.setAlignment( - cast( - "QtCore.Qt.Alignment", - QtCore.Qt.AlignmentFlag.AlignRight - | QtCore.Qt.AlignmentFlag.AlignVCenter, - ) - ) - layout.addWidget(lbl, 1, 0) - layout.addWidget(self.typeSelect, 1, 1) - - self.valsArgsEdit = QtWidgets.QLineEdit(self) - lbl = QtWidgets.QLabel("Type opts.:") - lbl.setToolTip( - "Optional, for constraining parameter values." - "Allowed args and defaults:\n" - " - 'Numeric': min_value=-1e18, max_value=1e18\n" - " - 'Integer': min_value=-inf, max_value=inf\n" - " - 'String': min_length=0, max_length=1e9\n" - "See qcodes.utils.validators for details." - ) - lbl.setAlignment( - cast( - "QtCore.Qt.Alignment", - QtCore.Qt.AlignmentFlag.AlignRight - | QtCore.Qt.AlignmentFlag.AlignVCenter, - ) - ) - layout.addWidget(lbl, 1, 2) - layout.addWidget(self.valsArgsEdit, 1, 3) - - self.addButton = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/plus-square.svg"), " Add", parent=self - ) - - self.addButton.clicked.connect(self.requestNewParameter) - self.nameEdit.returnPressed.connect(self.addButton.click) - self.valueEdit.returnPressed.connect(self.addButton.click) - self.unitEdit.returnPressed.connect(self.addButton.click) - layout.addWidget(self.addButton, 0, 6, 1, 1) - self.addButton.setAutoDefault(True) - - self.clearButton = QtWidgets.QPushButton( - QtGui.QIcon(":/icons/delete.svg"), " Clear", parent=self - ) - - self.clearButton.setAutoDefault(True) - self.clearButton.clicked.connect(self.clear) - layout.addWidget(self.clearButton, 0, 7, 1, 1) - - self.setLayout(layout) - self.invalidParamRequested.connect(self.setError) - - @QtCore.Slot() - def clear(self) -> None: - self.clearError() - self.nameEdit.setText("") - self.valueEdit.setText("") - self.unitEdit.setText("") - if self.typeInput: - self.typeSelect.setCurrentText( - parameterTypes[ParameterTypes.numeric]["name"] # type: ignore[arg-type] - ) - self.valsArgsEdit.setText("") - - @QtCore.Slot(bool) - def requestNewParameter(self, _: bool) -> None: - self.clearError() - - name = self.nameEdit.text().strip() - if len(name) == 0: - self.invalidParamRequested.emit("Name must not be empty.") - return - value = self.valueEdit.text() - unit = self.unitEdit.text() - - if hasattr(self, "typeSelect"): - ptype = paramTypeFromName(self.typeSelect.currentText()) - valsArgs = self.valsArgsEdit.text() - else: - ptype = ParameterTypes.any - valsArgs = "" - - self.newParamRequested.emit(name, value, unit, ptype, valsArgs) - - @QtCore.Slot(str) - def setError(self, message: str) -> None: - self.addButton.setStyleSheet(""" - QPushButton { background-color: red } - """) - self.addButton.setToolTip(message) - - def clearError(self) -> None: - self.addButton.setStyleSheet("") - self.addButton.setToolTip("") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") class MethodDisplay(QtWidgets.QWidget): @@ -308,10 +175,15 @@ def createEditor( # type: ignore[override] if not item.showDelegate: # type: ignore[attr-defined] return None # type: ignore[return-value] - element = item.element # type: ignore[attr-defined] - - ret = ParameterWidget(element, widget) + ret = self.makeParameterWidget(item, widget) self.parameters[item.name] = ret # type: ignore[attr-defined] + # Qt deletes the editor when its row is hidden (the filter, the + # trash toggle); forget it then, so nobody touches a dead widget + ret.destroyed.connect( + lambda _=None, name=item.name, editor=ret: self._forgetEditor( # type: ignore[attr-defined] + name, editor + ) + ) ret.valueCommitted.connect(self.parent().setFocus) # type: ignore[union-attr] if self.navFilter is not None: @@ -332,6 +204,21 @@ def createEditor( # type: ignore[override] # logger.warning(f"Failed to get value for parameter {element.name}: {e}") return ret + def _forgetEditor(self, name: str, editor: QtWidgets.QWidget) -> None: + """Drop the destroyed ``editor`` of row ``name``, unless a newer + editor for the same row has already replaced it.""" + if self.parameters.get(name) is editor: + del self.parameters[name] + + def makeParameterWidget( + self, item: QtGui.QStandardItem, parent: QtWidgets.QWidget + ) -> ParameterWidget: + """The editor widget for the parameter of ``item``. A delegate that + adds buttons next to the value overrides this; + :meth:`createEditor` does the rest (registering the widget and + installing the navigation filter).""" + return ParameterWidget(item.element, parent) # type: ignore[attr-defined] + class ValueCellNavigationFilter(QtCore.QObject): """Event filter installed on value input widgets to handle Escape, Tab, and Shift+Tab.""" @@ -416,8 +303,9 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: } super().__init__(*args, **kwargs) - self.setColumnCount(3) - self.setHorizontalHeaderLabels([self.attr, "unit", ""]) + labels = self.headerLabels() + self.setColumnCount(len(labels)) + self.setHorizontalHeaderLabels(labels) # Live updates items self.cliThread = QtCore.QThread() @@ -442,19 +330,35 @@ def stopListener(self) -> None: def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: fullName = ".".join(bp.name.split(".")[1:]) - if bp.action == "parameter-creation": - if fullName not in self.instrument.list(): - self.instrument.update() - if fullName in self.instrument.list(): - self.addItem( - fullName, - element=nestedAttributeFromString(self.instrument, fullName), - ) - - elif bp.action == "parameter-deletion": + if bp.action == PARAMETER_CREATION: + # Resolve the parameter on the instrument first: a parameter + # another Client created while the GUI is open is already in the + # Proxy's remote list(), so only a fresh resolution tells whether + # the (Proxy) instrument's blueprint is stale. On a stale one, + # update() refreshes it and the element resolves on the second + # attempt (TEST_AUDIT.md, "Parameter Manager GUI — live creation + # from another client"; fixed in plan task 5.5 by Marcos's + # decision, an explicit exception to plan rule 6). + try: + element = nestedAttributeFromString(self.instrument, fullName) + except AttributeError: + if hasattr(self.instrument, "update"): + self.instrument.update() + try: + element = nestedAttributeFromString(self.instrument, fullName) + except AttributeError: + logger.debug( + f"Ignoring parameter-creation broadcast for a " + f"parameter that cannot be resolved: {fullName}" + ) + element = None + if element is not None: + self.addItem(fullName, element=element) + + elif bp.action == PARAMETER_DELETION: self.removeItem(fullName) - elif bp.action == "parameter-update" or bp.action == "parameter-call": + elif bp.action == PARAMETER_UPDATE or bp.action == PARAMETER_CALL: item = self.findItems( fullName, cast( @@ -483,29 +387,40 @@ def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: # The model can't actually modify the widget since it knows nothing about the view itself. self.itemNewValue.emit(item[0].name, bp.value) + def headerLabels(self) -> List[str]: + """The header label of every column: name, unit and delegate. + A model with more columns extends this list and :meth:`rowItems` + together.""" + return [self.attr, "unit", ""] + + def rowItems(self, item: QtGui.QStandardItem) -> List[QtGui.QStandardItem]: + """The items of one row, ``item`` first, one per column of + :meth:`headerLabels`. :meth:`insertItemTo` inserts them.""" + # A parameter might not have a unit + unit = "" + if item.element is not None: # type: ignore[attr-defined] + unit = item.element.unit # type: ignore[attr-defined] + return [item, QtGui.QStandardItem(unit), QtGui.QStandardItem()] + def insertItemTo( self, parent: QtGui.QStandardItem, item: QtGui.QStandardItem ) -> None: if item is not None: - # A parameter might not have a unit - unit = "" - if item.element is not None: # type: ignore[attr-defined] - unit = item.element.unit # type: ignore[attr-defined] - unitItem = QtGui.QStandardItem(unit) - extraItem = QtGui.QStandardItem() - + row = self.rowItems(item) if parent == self: rowCount = self.rowCount() - self.setItem(rowCount, 0, item) - self.setItem(rowCount, 1, unitItem) - self.setItem(rowCount, 2, extraItem) + for column, cell in enumerate(row): + self.setItem(rowCount, column, cell) else: - parent.appendRow([item, unitItem, extraItem]) + parent.appendRow(row) self.newItem.emit(item) class ParametersTreeView(InstrumentTreeViewBase): + #: The delegate class of the value column (column 2). + delegateClass: type[ParameterDelegate] = ParameterDelegate + def __init__( self, model: QtCore.QAbstractItemModel, @@ -514,15 +429,24 @@ def __init__( ) -> None: super().__init__(model, [2], *args, **kwargs) - self.delegate = ParameterDelegate(self) + self.delegate = self.delegateClass(self) self.delegate.navFilter = ValueCellNavigationFilter(self) self.setItemDelegateForColumn(2, self.delegate) + self.setupColumns() self.setAllDelegatesPersistent() + def setupColumns(self) -> None: + """Set up the delegates and header of any columns beyond name, + unit and value. Runs before the persistent editors are opened; + the default does nothing.""" + @QtCore.Slot(object, object) def onItemNewValue(self, itemName: str, value: Any) -> None: - widget = self.delegate.parameters[itemName] + widget = self.delegate.parameters.get(itemName) + if widget is None: + # the row is hidden, so it has no editor to update + return try: # use the abstract set method defined in parameter widget so it works for different types of widgets widget._setMethod(value) @@ -539,6 +463,7 @@ def __init__( parent: Optional[QtWidgets.QWidget] = None, viewType: type = ParametersTreeView, callSignals: bool = True, + modelType: type = ModelParameters, **kwargs: Any, ) -> None: if "instrument" in kwargs: @@ -564,7 +489,7 @@ def __init__( parent=parent, attr="parameters", itemType=ItemParameters, - modelType=ModelParameters, + modelType=modelType, viewType=viewType, callSignals=callSignals, shortcutManager=shortcutManager, @@ -636,235 +561,6 @@ def _clearCurrentParameter(self) -> None: # ----------------- Parameters Display Classes - Ending -------------------------------- -# ----------------- Parameters Manager Classes - Beginning ----------------------------- - - -class ParameterDeleteDelegate(ParameterDelegate): - #: Signal(str) - #: Emits the name of the parameter to be deleted when the user presses the delete button. - removeParameter = QtCore.Signal(str) - - def createEditor( # type: ignore[override] - self, - widget: QtWidgets.QWidget, - option: QtWidgets.QStyleOptionViewItem, - index: QtCore.QModelIndex, - ) -> QtWidgets.QWidget: - item = self.getItem(index) - - if not item.showDelegate: # type: ignore[attr-defined] - return None # type: ignore[return-value] - - element = item.element # type: ignore[attr-defined] - rw = self.makeRemoveWidget(item.name, widget) # type: ignore[attr-defined] - - ret = ParameterWidget(parameter=element, parent=widget, additionalWidgets=[rw]) - self.parameters[item.name] = ret # type: ignore[attr-defined] - ret.valueCommitted.connect(self.parent().setFocus) # type: ignore[union-attr] - - if self.navFilter is not None: - if isinstance(ret.paramWidget, AnyInput): - input_widget = ret.paramWidget.input - else: - input_widget = ret.paramWidget - input_widget.installEventFilter(self.navFilter) - self.navFilter.registerWidget(input_widget, index) - - return ret - - def makeRemoveWidget( - self, fullName: str, widget: QtWidgets.QWidget - ) -> QtWidgets.QPushButton: - w = QtWidgets.QPushButton(QtGui.QIcon(":/icons/delete.svg"), "", parent=widget) - w.setStyleSheet(""" - QPushButton { background-color: salmon } - """) - w.setToolTip("Delete this parameter") - keepSmallHorizontally(w) - - w.pressed.connect(lambda: self.removeParameter.emit(fullName)) - return w - - -# TODO: Make sure that the refresh button refreshes the profiles as well as the model -class ParameterManagerTreeView(InstrumentTreeViewBase): - def __init__( - self, - model: QtCore.QAbstractItemModel, - *args: Any, - **kwargs: Any, - ) -> None: - super().__init__(model, [2], *args, **kwargs) - - self.delegate = ParameterDeleteDelegate(self) - self.delegate.navFilter = ValueCellNavigationFilter(self) - - self.setItemDelegateForColumn(2, self.delegate) - self.setAllDelegatesPersistent() - - @QtCore.Slot(object, object) - def onItemNewValue(self, itemName: str, value: Any) -> None: - widget = self.delegate.parameters[itemName] - widget.paramWidget.setValue(value) - - -class ProfilesManager(QtWidgets.QComboBox): - #: Signal() - #: Emitted when the selected index changed. - indexChanged = QtCore.Signal() - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - - self.setEditable(False) - self.params = self.parent().instrument # type: ignore[union-attr] - self.refreshing = False - - loadingProfile = None - for profile in self.params.list_profiles(): - self.addItem(self.params.cleanProfileName(profile)) - if loadingProfile is None: - loadingProfile = profile - - self.currentIndexChanged.connect(self.onCurrentIndexChanged) - - def refresh(self) -> None: - self.refreshing = True - currentlySelected = self.currentText() - self.clear() - for profile in self.params.list_profiles(): - self.addItem(self.params.cleanProfileName(profile)) - if self.params.cleanProfileName(profile) == currentlySelected: - self.setCurrentIndex(self.count() - 1) - self.refreshing = False - - @QtCore.Slot(int) - def onCurrentIndexChanged(self, index: int) -> None: - if not self.refreshing: - self.indexChanged.emit() - - -class ParameterManagerGui(InstrumentParameters): - #: Signal(str) -- - #: emitted when there's an error during parameter creation. - parameterCreationError = QtCore.Signal(str) - - #: Signal() -- - #: emitted when a parameter was created successfully - parameterCreated = QtCore.Signal() - - def __init__( - self, - instrument: Union[ProxyInstrument, ParameterManager], - parent: Optional[QtWidgets.QWidget] = None, - **kwargs: Any, - ) -> None: - super().__init__( - instrument, - parent=None, - viewType=ParameterManagerTreeView, - callSignals=False, - **kwargs, - ) - self.profileManager = ProfilesManager(parent=self) - self.addParam = AddParameterWidget(parent=self) - layout = self.layout() - assert isinstance(layout, QtWidgets.QVBoxLayout) - layout.insertWidget(0, self.profileManager) - layout.addWidget(self.addParam) - self.connectSignals() - self.loadProfile() - - def connectSignals(self) -> None: - super().connectSignals() - self.view.delegate.removeParameter.connect(self.removeParameter) - self.addParam.newParamRequested.connect(self.addParameter) - self.parameterCreationError.connect(self.addParam.setError) - self.parameterCreated.connect(self.addParam.clear) - self.profileManager.indexChanged.connect(self.loadProfile) - self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) - self.shortcutManager.register("clear_add", self.addParam.clear, self) - self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) - self.shortcutManager.register("load_items", self.loadFromFile, self) - self.shortcutManager.register("save_items", self.saveToFile, self) - - @QtCore.Slot() - def _deleteCurrentItem(self) -> None: - item = self._getCurrentItem() - if item is not None: - self.removeParameter(item.name) - - def makeToolbar(self) -> QtWidgets.QToolBar: - toolbar = super().makeToolbar() - - toolbar.addSeparator() - - loadParamAction = toolbar.addAction( - QtGui.QIcon(":/icons/load.svg"), - "Load parameters from file", - ) - loadParamAction.triggered.connect(lambda x: self.loadFromFile()) # type: ignore[union-attr] - self.shortcutManager.register_tooltip("load_items", loadParamAction) - - saveParamAction = toolbar.addAction( - QtGui.QIcon(":/icons/save.svg"), - "Save parameters to file", - ) - saveParamAction.triggered.connect(lambda x: self.saveToFile()) # type: ignore[union-attr] - self.shortcutManager.register_tooltip("save_items", saveParamAction) - - return toolbar - - def refreshAll(self) -> None: - super().refreshAll() - self.instrument.refresh_profiles() - self.profileManager.refresh() - - def removeParameter(self, fullName: str) -> None: - if self.instrument.has_param(fullName): - self.instrument.remove_parameter(fullName) - - def addParameter(self, fullName: str, value: Any, unit: str) -> None: - try: - # Validators are commented out until they can be serialized. - self.instrument.add_parameter( - fullName, - initial_value=value, - unit=unit, - ) # vals=vals) - self.parameterCreated.emit() - except Exception as e: - self.parameterCreationError.emit( - f"Could not create parameter.Adding parameter raised{type(e)}: {e.args}" - ) - return - - @QtCore.Slot() - def loadProfile(self) -> None: - profileName = self.profileManager.currentText() - self.instrument.switch_to_profile(profileName) - super().refreshAll() - self.instrument.refresh_profiles() - - @QtCore.Slot() - def loadFromFile(self, loadFile: Optional[str] = None) -> None: - try: - self.instrument.fromFile(filePath=loadFile, deleteMissing=False) - self.refreshAll() - - except Exception as e: - logger.info(f"Loading failed. {type(e)}: {e.args}") - - @QtCore.Slot() - def saveToFile(self) -> None: - try: - self.instrument.toFile() - except Exception as e: - logger.info(f"Saving failed. {type(e)}: {e.args}") - - -# ----------------- Parameters Manager Classes - Ending -------------------------------- - # ----------------- Methods Display Classes - Beginning -------------------------------- diff --git a/src/instrumentserver/gui/parameter_manager/__init__.py b/src/instrumentserver/gui/parameter_manager/__init__.py new file mode 100644 index 0000000..55aa4d9 --- /dev/null +++ b/src/instrumentserver/gui/parameter_manager/__init__.py @@ -0,0 +1,16 @@ +"""The Parameter Manager GUI. + +- :mod:`.logic` holds what builds no widgets: the Type claims and tint + palette, the Lock rows and texts, the Types tab rows, and :class:`PMState`. +- :mod:`.panels` holds the gutter delegate, the Lock arm strip, the Locks + panel and the Types tab. +- :mod:`.widget` holds :class:`ParameterManagerGui` and the model, tree view + and create form it is built from. + +Station configs name the widget as +``instrumentserver.gui.parameter_manager.ParameterManagerGui``; the older +``instrumentserver.gui.instruments.ParameterManagerGui`` still works. +""" + +from .logic import PMState # noqa: F401 +from .widget import ModelParameterManager, ParameterManagerGui # noqa: F401 diff --git a/src/instrumentserver/gui/parameter_manager/logic.py b/src/instrumentserver/gui/parameter_manager/logic.py new file mode 100644 index 0000000..a16be1a --- /dev/null +++ b/src/instrumentserver/gui/parameter_manager/logic.py @@ -0,0 +1,831 @@ +"""Parameter Manager GUI logic that builds no widgets: the Type claims and +tint palette, the Lock rows and texts, the Types tab rows, and the +:class:`PMState` cache of Types and Locks.""" + +import ast +from dataclasses import dataclass +from typing import ( + Any, + Dict, + Iterable, + List, + Mapping, + Optional, + Tuple, + cast, +) + +from ... import QtCore, QtGui +from ...blueprints import ( + PMLockBluePrint, + PMTypeBluePrint, +) + +# ----------------- Tints -------------------------------------------------------------- + + +#: Logical index of the gutter column of :class:`.ModelParameterManager`, +#: whose items carry a row's stack of Types for the +#: :class:`.GutterDelegate` to draw. The existing columns keep their +#: indexes: name (0), unit (1), delegate (2). +GUTTER_COLUMN = 3 + +#: Fixed pixel width of the gutter column in the view. +GUTTER_WIDTH = 12 + +#: Data role under which a row's stack of Type names is stored on its +#: gutter item; :class:`.GutterDelegate` reads it to draw the bands. +GUTTER_ROLE = cast("QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 1) + +#: The mock's TINTS for a light theme: ``tint`` and ``tintAlt`` are the +#: row background of a claimed row (``tintAlt`` for every other sibling +#: row), ``bar`` the colour of its gutter band. The slot of a Type is its +#: index in this list. +TINT_PALETTE: List[Dict[str, str]] = [ + {"tint": "#e8f1fb", "tintAlt": "#dfe9f6", "bar": "#4a7fc1"}, + {"tint": "#e9f4e9", "tintAlt": "#e0ede0", "bar": "#4f9e57"}, + {"tint": "#f6efe4", "tintAlt": "#efe7db", "bar": "#b98a3e"}, + {"tint": "#f9ecec", "tintAlt": "#f2e3e3", "bar": "#b5605f"}, + {"tint": "#e5f4f2", "tintAlt": "#dcece9", "bar": "#3f9490"}, +] + +#: The same five hues for a dark theme, slot for slot: dark, low-saturation +#: tints that keep the theme's light text readable, and brighter bars so +#: the gutter bands stand out on a dark background. +TINT_PALETTE_DARK: List[Dict[str, str]] = [ + {"tint": "#1e2a3a", "tintAlt": "#233245", "bar": "#5b8fd1"}, + {"tint": "#1e2e21", "tintAlt": "#243627", "bar": "#5fae67"}, + {"tint": "#33291b", "tintAlt": "#3b3020", "bar": "#c99a4e"}, + {"tint": "#352122", "tintAlt": "#3e2728", "bar": "#c5706f"}, + {"tint": "#1b302e", "tintAlt": "#213835", "bar": "#4fa4a0"}, +] + +#: The palettes as QColors, in the same slot order. +TINT_COLOURS: List[Dict[str, QtGui.QColor]] = [ + {name: QtGui.QColor(value) for name, value in entry.items()} + for entry in TINT_PALETTE +] +TINT_COLOURS_DARK: List[Dict[str, QtGui.QColor]] = [ + {name: QtGui.QColor(value) for name, value in entry.items()} + for entry in TINT_PALETTE_DARK +] + + +def is_dark_theme() -> bool: + """Whether the application currently uses a dark theme: its palette's + window colour is darker than its window text. Reading the palette + (rather than the platform's colour scheme) also covers a dark palette + or style sheet set on the application itself.""" + palette = QtGui.QGuiApplication.palette() + window = palette.color(QtGui.QPalette.ColorRole.Window) + text = palette.color(QtGui.QPalette.ColorRole.WindowText) + return window.lightness() < text.lightness() + + +def tint_colours() -> List[Dict[str, QtGui.QColor]]: + """The tint palette for the current theme (:func:`is_dark_theme`).""" + return TINT_COLOURS_DARK if is_dark_theme() else TINT_COLOURS + + +@dataclass +class Claim: + """What the tree shows for one row that Types carry (the mock's + ``claims()``): the Claiming Type whose tint the row shows, the Instance + submodule path that claims it, and every Type carrying the row, + outermost first (the gutter draws one band per Type, up to three).""" + + type: str + instance: str + stack: List[str] + + +def _nested_claim_prefixes( + blueprint: PMTypeBluePrint, + types: Mapping[str, PMTypeBluePrint], +) -> Dict[str, str]: + """Map every effective path of the Type ``blueprint`` that a Nested + Type defines to the dotted submodule chain under which its defining + Type sits (the mock's ``at``): a ``qubit`` nesting a ``readout`` at its + submodule ``readout``, with the ``readout`` nesting a ``pulse_window`` + at ``pw``, maps the effective path ``readout.pw.win`` to + ``readout.pw``. + + Mirrors how ``params.py`` expands the effective set + (``_collect_effective``): the entries a Type defines itself are left + out (they claim at the Instance itself) and each Nested Type's own + entries are recorded under the chain that leads to it. + """ + at_by_path: Dict[str, str] = {} + + def walk(blueprint: PMTypeBluePrint, prefix: str, seen: Tuple[str, ...]) -> None: + for submodule, nested_name in blueprint.nested.items(): + if nested_name in seen: + continue # cycles are refused by the Parameter Manager + nested = types.get(nested_name) + if nested is None: + continue + at = prefix + submodule + for path, spec in nested.effective.items(): + if spec.get("from_type") == nested_name: + at_by_path[f"{at}.{path}"] = at + walk(nested, f"{at}.", seen + (nested_name,)) + + walk(blueprint, "", (blueprint.name,)) + return at_by_path + + +def _carries_effective_set( + instance: str, + effective: Mapping[str, Mapping[str, str]], + parameters: Mapping[str, str], +) -> bool: + """Whether the candidate Instance ``instance`` carries every path of + the effective set ``effective`` with the unit the Type declares (D12): + matching requires existence and unit, compared as strings; values are + irrelevant.""" + prefix = f"{instance}." + for path, spec in effective.items(): + if parameters.get(prefix + path) != spec["unit"]: + return False + return True + + +def _instance_candidates(parameters: Mapping[str, str]) -> List[str]: + """Every submodule path the parameter rows imply, sorted: every proper + dotted prefix of a parameter path, never the root and never anything + under the Globals submodule (D12). This is the candidate set both + :func:`compute_claims` and :func:`instances_of_type` match against.""" + candidates = set() + for path in parameters: + segments = path.split(".") + for depth in range(1, len(segments)): + candidate = ".".join(segments[:depth]) + if "_globals" in candidate.split("."): + continue # Globals is excluded from matching at any depth + candidates.add(candidate) + return sorted(candidates) + + +def compute_claims( + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], +) -> Dict[str, Claim]: + """The mock's ``claims()`` ported to the client-side state (plan task + 5.2): which Type claims each row of the Parameter Manager tree, and + which stack of Types carries it. + + :param types: the Parameter Manager's Types (``PMState.types``), each + as its :class:`PMTypeBluePrint`. + :param parameters: every parameter row of the tree as ``{path relative + to the Parameter Manager: unit}``. + :return: for every claimed parameter path and submodule path, its + :class:`Claim`. + + Matching mirrors ``ParameterManager.instances_of`` (D12) client-side: + a candidate is every submodule path derived from the parameter paths + (every proper dotted prefix; never the root, never anything under + Globals) and it is an Instance when it carries every effective path + with the declared unit. The Claiming Type is the innermost (the + longest Instance path), then the largest effective set, then the Type + name. A Nested Type claims at and below its submodule, so a row it + defines is claimed by it, with the outer Types behind it in the stack. + """ + # candidate Instances: every proper dotted prefix of a parameter path + candidates = _instance_candidates(parameters) + + claims_by_path: Dict[str, List[Tuple[str, str, int]]] = {} + winning: Dict[str, Tuple[str, str, int]] = {} + + def put(path: str, type_name: str, instance: str, size: int) -> None: + # one (Type, Instance, effective set size) claim, as the mock's + # all/map pair; the winner keeps the innermost Instance, then the + # largest effective set, then the Type name + claim = (type_name, instance, size) + claims_by_path.setdefault(path, []).append(claim) + old = winning.get(path) + if old is None or (-len(instance), -size, type_name) < ( + -len(old[1]), + -old[2], + old[0], + ): + winning[path] = claim + + for type_name, blueprint in types.items(): + effective = blueprint.effective + if not effective: + continue # an empty Type has no Instances + size = len(effective) + at_by_path = _nested_claim_prefixes(blueprint, types) + for instance in candidates: + if not _carries_effective_set(instance, effective, parameters): + continue + # the Instance row itself is claimed by its Type, as in the mock + put(instance, type_name, instance, size) + for path in effective: + # every row at and above the parameter, down to the + # parameter itself, is claimed at the Instance + at = at_by_path.get(path, "") + spec = effective[path] + owner_instance = f"{instance}.{at}" if at else None + at_depth = len(at.split(".")) if at else 0 + segments = path.split(".") + for depth in range(1, len(segments) + 1): + row = f"{instance}.{'.'.join(segments[:depth])}" + put(row, type_name, instance, size) + if owner_instance is not None and depth >= at_depth: + # the Nested Type claims at and below its submodule + put(row, spec["from_type"], owner_instance, size) + + claims: Dict[str, Claim] = {} + for path, path_claims in claims_by_path.items(): + # the stack is every Type carrying the row, outermost first + # (shortest Instance path, then the larger effective set), + # de-duplicated by Type + stack: List[str] = [] + for name in [ + entry[0] + for entry in sorted( + path_claims, key=lambda entry: (len(entry[1]), -entry[2], entry[0]) + ) + ]: + if name not in stack: + stack.append(name) + type_name, instance, _ = winning[path] + claims[path] = Claim(type=type_name, instance=instance, stack=stack) + return claims + + +class TypePalette: + """Assigns the fixed tint palette's slots to the Types the GUI knows. + + A Type keeps its slot while it exists: the slot is assigned when the + GUI first sees the Type (in ``PMState.types`` order after a refresh, + then each new Type from a ``pm-type-update`` Broadcast), it never + changes while the Type is in the state, and it is freed when the Type + is removed. A new Type takes the lowest free slot, or slot 0 when all + five are used (the mock's ``freeTint`` recycles when exhausted). + """ + + def __init__(self) -> None: + self.slots: Dict[str, int] = {} + + def sync(self, type_names: Any) -> None: + """Free the slots of Types that are gone and assign slots to new + ones, in the given creation order. + + :param type_names: the names of the Types the GUI knows + (``PMState.types``). + """ + names = list(type_names) + for name in [known for known in self.slots if known not in names]: + del self.slots[name] + used = set(self.slots.values()) + for name in names: + if name in self.slots: + continue + slot = next( + (index for index in range(len(TINT_PALETTE)) if index not in used), + 0, + ) + self.slots[name] = slot + used.add(slot) + + def colours(self, type_name: str) -> Optional[Dict[str, QtGui.QColor]]: + """The palette entry of the Type ``type_name`` (``tint``, + ``tintAlt`` and ``bar``) for the current theme, or ``None`` when it + has no slot.""" + slot = self.slots.get(type_name) + return None if slot is None else tint_colours()[slot] + + def bar_colour(self, type_name: str) -> Optional[QtGui.QColor]: + """The gutter band colour of the Type ``type_name``.""" + colours = self.colours(type_name) + return None if colours is None else colours["bar"] + + +# ----------------- Locks -------------------------------------------------------------- + + +#: Logical index of the Lock column of :class:`.ModelParameterManager` +#: (plan task 5.3). The existing columns keep their indexes: name (0), +#: unit (1), delegate (2), gutter (3). The view shows the Lock column +#: between the unit and the delegate column. +LOCK_COLUMN = 4 + +#: Fixed default pixel width of the Lock column in the view (the user can +#: resize it: the section is Interactive). +LOCK_COLUMN_WIDTH = 140 + +#: The mock's one purple (its ``--log-value`` token): the fill of a row's +#: lock button while its Lock is locked. +LOCK_COLOUR = "#7e5bef" + + +def lock_button_tooltip(locked: bool, target: str) -> str: + """The lock/relock button's tooltip for one Lock state (the mock's + strings), with ``target`` relative to the Parameter Manager. Shared by + the tree's per-row widget (plan task 5.3) and the Locks panel (plan + task 5.4).""" + if locked: + return f"locked to {target} — unlock and go back to its own value" + return f"unlocked — lock to {target} again" + + +def relative_path(full: str, instrument_name: str) -> str: + """The path relative to the Parameter Manager: ``full`` with the + ``.`` prefix stripped. ``PMLockBluePrint.target`` + stores the full dotted path, while model item names and every string + the GUI shows the user are relative to the Parameter Manager.""" + prefix = f"{instrument_name}." + return full[len(prefix) :] if full.startswith(prefix) else full + + +def lock_column_text( + path: str, + locks: Mapping[str, PMLockBluePrint], + instrument_name: str, +) -> str: + """The text the Lock column shows for the parameter row ``path`` (a + path relative to the Parameter Manager), computed client-side over the + state's Locks (plan task 5.3; the mock's lock cell). + + A Follower shows its own Lock state: ``locked to `` while + locked, ``unlocked · `` (middle dot) while unlocked, with the + Target relative to the Parameter Manager. A parameter that is no + Follower but the Target of ``N`` Locks — locked and unlocked alike, + the way :meth:`ParameterManager.followers_of` counts — shows + ``target ×N`` (multiplication sign). Every other row shows nothing. + + A row that is both Follower and Target shows its Follower text, which + wins over the Target note (the mock's ``rec.lockedTo || srcNote(p)``). + """ + lock = locks.get(path) + if lock is not None: + # the Follower's own Lock state wins over the Target note + target = relative_path(lock.target, instrument_name) + if lock.locked: + return f"locked to {target}" + return f"unlocked · {target}" + full_path = f"{instrument_name}.{path}" + count = sum(1 for other in locks.values() if other.target == full_path) + if count: + return f"target ×{count}" + return "" + + +def followers_reaching( + path: str, + locks: Mapping[str, PMLockBluePrint], + instrument_name: str, +) -> List[str]: + """Paths (relative to the Parameter Manager) of every Follower whose + locked Lock targets the parameter at ``path``, directly or over a + chain of locked Locks. + + Only locked hops count (D7): an unlocked Lock answers ``get`` with its + own value, so the Followers behind it do not see an update made past + it. The walk follows each hop's Target and stops there — no infinite + loop on a cycle, and every Follower appears once. + """ + prefix = f"{instrument_name}." + found: List[str] = [] + seen: set = set() + targets = [prefix + path] + index = 0 + while index < len(targets): + current = targets[index] + index += 1 + for follower, lock in locks.items(): + if not lock.locked or lock.target != current or follower in seen: + continue + seen.add(follower) + found.append(follower) + targets.append(prefix + follower) + return found + + +def rank_lock_targets( + follower: str, + candidates: Iterable[str], + claims: Mapping[str, Claim], + arm_rel: Optional[str] = None, +) -> List[str]: + """The arm strip's Target candidates in the mock's completer order. + + ``arm_rel`` is the Follower's path relative to its Instance (the part + behind the Claiming Type's Instance path), or ``None`` when the + Follower is claimed by no Type; an explicit ``arm_rel`` argument + overrides it, which the Types tab's Type Lock re-target (plan task + 5.5) uses to rank for a Type's entry path — there is no claimed + Follower and so nothing to exclude. Rank 0: the candidate's own + relative path equals ``arm_rel`` (the same leaf on a sibling Instance, + the mock's first pick). Rank 1: ``.`` occurs in the candidate + (a submodule on the way). Rank 2: everything else. Equal ranks order + alphabetically; the Follower itself is never a candidate. Cycles are + not filtered here: the Server refuses them and the arm strip shows its + error text. + """ + if arm_rel is None: + follower_claim = claims.get(follower) + arm_rel = ( + follower[len(follower_claim.instance) + 1 :] + if follower_claim is not None + else None + ) + + def own_rel(candidate: str) -> Optional[str]: + claim = claims.get(candidate) + if claim is None: + return None + return candidate[len(claim.instance) + 1 :] + + ranked: List[Tuple[int, str]] = [] + for candidate in candidates: + if candidate == follower: + continue # the Follower itself is never a candidate + rel = own_rel(candidate) + if arm_rel is not None and rel == arm_rel: + rank = 0 + elif arm_rel is not None and f".{arm_rel}" in candidate: + rank = 1 + else: + rank = 2 + ranked.append((rank, candidate)) + ranked.sort(key=lambda entry: (entry[0], entry[1])) + return [path for _, path in ranked] + + +@dataclass +class LockRow: + """One row of the Locks panel (plan task 5.4): a Target of one or more + Locks — plain, or the Target of a Type Lock — and the Followers beneath + it, recursively for chains. ``type_locks`` holds every ``(Type name, + entry path)`` whose Type Lock Target the row is; ``lock`` is the row's + own Lock (``None`` for a plain Target).""" + + path: str + type_locks: List[Tuple[str, str]] + lock: Optional[PMLockBluePrint] + children: List["LockRow"] + + +def lock_root( + path: str, + locks: Mapping[str, PMLockBluePrint], + instrument_name: str, +) -> str: + """The end of the chain of locked Locks that starts at ``path`` (the + mock's ``root``): the parameter a locked read at ``path`` finally asks. + Only locked hops count (D7): an unlocked Lock answers ``get`` with its + own value, so the walk stops there. A ``seen`` set guards against a + cycle. Paths are relative to the Parameter Manager, except the stored + ``PMLockBluePrint.target``, which is relativized on the way.""" + current = path + seen: set = set() + while current not in seen: + seen.add(current) + lock = locks.get(current) + if lock is None or not lock.locked: + return current + current = relative_path(lock.target, instrument_name) + return current + + +def build_lock_rows( + locks: Mapping[str, PMLockBluePrint], + types: Mapping[str, PMTypeBluePrint], + instrument_name: str, +) -> List[LockRow]: + """The Locks panel's rows from the client-side state (plan task 5.4; + the mock's locks-panel walk). + + ``locks`` maps each Follower's path (relative to the Parameter + Manager) to its :class:`PMLockBluePrint`; ``types`` maps each Type's + name to its :class:`PMTypeBluePrint`. An unlocked Lock still + remembers its Target (D5), so a Follower's Lock names its Target + whether the Lock is locked or not. + + The Targets are the unique Targets of the Locks, in ``locks`` order. + The roots are the Targets that carry no Lock of their own, the Type + Lock Targets first (a stable sort, like the mock's), each walked + recursively into its Followers — a ``seen`` set guards against loops — + and then any Target the first walk did not reach (the mock's second + pass, e.g. a cycle among Followers). Every row carries its own Lock + (``None`` for a plain Target) and its ``(Type, entry)`` pairs. + """ + + def target_of(follower: str) -> Optional[str]: + lock = locks.get(follower) + return None if lock is None else relative_path(lock.target, instrument_name) + + targets: List[str] = [] + for follower in locks: + target = target_of(follower) + if target is not None and target not in targets: + targets.append(target) + + def type_locks_at(path: str) -> List[Tuple[str, str]]: + found: List[Tuple[str, str]] = [] + for type_name, blueprint in types.items(): + for entry_path, spec in blueprint.parameters.items(): + entry_target = spec.get("target") + if ( + entry_target is not None + and relative_path(entry_target, instrument_name) == path + ): + found.append((type_name, entry_path)) + return found + + def followers(path: str) -> List[str]: + return [follower for follower in locks if target_of(follower) == path] + + rows: List[LockRow] = [] + seen: set = set() + + def walk(path: str) -> Optional[LockRow]: + if path in seen: + return None + seen.add(path) + row = LockRow( + path=path, + type_locks=type_locks_at(path), + lock=locks.get(path), + children=[], + ) + for child_path in followers(path): + child = walk(child_path) + if child is not None: + row.children.append(child) + return row + + # Type Lock Targets first, plain Targets follow — a stable sort, + # like the mock's + roots = [target for target in targets if target not in locks] + roots.sort(key=lambda target: 0 if type_locks_at(target) else 1) + for target in roots: + row = walk(target) + if row is not None: + rows.append(row) + # the mock's second pass: any Target the first walk did not reach + for target in targets: + row = walk(target) + if row is not None: + rows.append(row) + return rows + + +def lock_row_paths(rows: List[LockRow]) -> List[str]: + """Every row path of the built rows, depth first.""" + paths: List[str] = [] + for row in rows: + paths.append(row.path) + paths.extend(lock_row_paths(row.children)) + return paths + + +# ----------------- Types tab ---------------------------------------------------------- + + +@dataclass +class EntryRow: + """One row of the Types tab's entries pane (plan task 5.5; the mock's + ``tParamRows``): a submodule row of the selected Type's tree, or one + entry of it. + + ``kind`` is ``"submodule"`` or ``"entry"``. A submodule row carries + ``nested_type`` — the Type required at that submodule, or ``None`` for + a structural row that only carries the rows below it. An entry row + carries + the effective entry's ``unit`` and defining Type (``from_type``), + whether the selected Type defines the entry itself (``own``), its + ``default`` — an own entry's stored default, a Nested Type entry's + default as stored on the defining Type — and, own entries only, the + ``target`` of the entry's Type Lock relative to the Parameter Manager + (``None`` while it has none). + """ + + path: str + kind: str + unit: str = "" + nested_type: Optional[str] = None + own: bool = False + from_type: Optional[str] = None + default: Any = None + target: Optional[str] = None + + +def _nested_type_at( + blueprint: PMTypeBluePrint, + types: Mapping[str, PMTypeBluePrint], + submodule: str, +) -> Optional[str]: + """The Type required at the submodule ``submodule`` (a dotted path + relative to the Type ``blueprint``): the walk follows the ``nested`` + maps down the segments, the way :func:`_nested_claim_prefixes` walks. + ``None`` when no Nested Type is required there — a structural row — + or when a nested Type of the chain is missing from ``types``.""" + current: Optional[PMTypeBluePrint] = blueprint + for segment in submodule.split("."): + if current is None: + return None + nested_name = current.nested.get(segment) + if nested_name is None: + return None + current = types.get(nested_name) + return current.name if current is not None else None + + +def type_entry_rows( + type_name: str, + types: Mapping[str, PMTypeBluePrint], + instrument_name: str = "", +) -> List[EntryRow]: + """The entries-pane rows of the Type ``type_name`` (plan task 5.5): + its effective parameter set as a segment-sorted tree of submodule and + entry rows. + + The sort is segment-wise like the mock's (paths order by their dotted + segments), so a submodule row sorts directly before the rows below it + and the list reads as a tree in order. + + An entry the Type defines itself (``from_type == type_name``) is + ``own``: its ``default`` and ``target`` come from the Type's own + entry, with the stored Type Lock Target relativized with + ``instrument_name``. An entry a Nested Type defines shows that Type + as ``from_type`` and the defining Type's own default for the path + relative to it (the mock's ``ownerRel``). + + :param type_name: the selected Type's name. + :param types: the Parameter Manager's Types (``PMState.types``). + :param instrument_name: the Parameter Manager's name, for + relativizing the stored Type Lock Targets; without it the stored + full-form Targets are returned unchanged. + :return: the rows, parents before children. + """ + blueprint = types.get(type_name) + if blueprint is None: + return [] + at_by_path = _nested_claim_prefixes(blueprint, types) + rows: List[EntryRow] = [] + submodule_paths: set = set() + for path in sorted(blueprint.effective, key=lambda entry: entry.split(".")): + segments = path.split(".") + for depth in range(1, len(segments)): + submodule = ".".join(segments[:depth]) + if submodule in submodule_paths: + continue + submodule_paths.add(submodule) + rows.append( + EntryRow( + path=submodule, + kind="submodule", + nested_type=_nested_type_at(blueprint, types, submodule), + ) + ) + spec = blueprint.effective[path] + from_type = spec["from_type"] + own = from_type == type_name + if own: + entry = blueprint.parameters.get(path, {}) + default = entry.get("default") + target = entry.get("target") + if target is not None and instrument_name: + target = relative_path(target, instrument_name) + else: + at = at_by_path.get(path, "") + relative = path[len(at) + 1 :] if at else path + defining = types.get(from_type) + default = ( + defining.parameters.get(relative, {}).get("default") + if defining is not None + else None + ) + target = None + rows.append( + EntryRow( + path=path, + kind="entry", + unit=spec["unit"], + own=own, + from_type=from_type, + default=default, + target=target, + ) + ) + return rows + + +def instances_of_type( + type_name: str, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], +) -> List[str]: + """Paths (relative to the Parameter Manager) of every Instance of the + Type ``type_name``, computed client-side over the model's parameter + rows (plan task 5.5; the mock's ``instancesOf``): the same candidate + rules :func:`compute_claims` matches by — every proper dotted prefix, + never the root, never anything under Globals — carrying every + effective path with the declared unit (D12). An empty Type has no + Instances. The Instances are sorted, for a stable pane order.""" + blueprint = types.get(type_name) + if blueprint is None: + return [] + effective = blueprint.effective + if not effective: + return [] + return [ + candidate + for candidate in _instance_candidates(parameters) + if _carries_effective_set(candidate, effective, parameters) + ] + + +def also_types( + instance: str, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], +) -> List[str]: + """Every Type the submodule ``instance`` is an Instance of (plan task + 5.5; the mock's ``also`` cell), in ``types`` order. The Types pane + shows the ones besides the selected Type as ``also , ``.""" + return [ + type_name + for type_name in types + if instance in instances_of_type(type_name, types, parameters) + ] + + +def parse_default_text(text: str) -> Any: + """The value a default line edit's text stands for: ``None`` when the + text is empty, otherwise the text parsed with ``ast.literal_eval``, + falling back to the raw string when it does not parse.""" + if text.strip() == "": + return None + try: + return ast.literal_eval(text) + except (ValueError, SyntaxError): + return text + + +# ----------------- Client-side state -------------------------------------------------- + + +class PMState: + """Client-side cache of a Parameter Manager's Types and Locks. + + The Parameter Manager GUI owns one instance (``ParameterManagerGui.state``) + so its widgets can react to Types and Locks without querying the Server + again. It starts empty and is filled from the Parameter Manager — a Proxy + Instrument or a local one — with :meth:`refresh`; the ``pm-lock-update`` + and ``pm-type-update`` Broadcasts then keep single entries current through + :meth:`apply_lock` and :meth:`apply_type` (D22). + + ``types`` maps each Type's name to its :class:`PMTypeBluePrint`; ``locks`` + maps each Follower's path relative to the Parameter Manager — the form + ``list_locks()`` returns — to its :class:`PMLockBluePrint`. + """ + + def __init__(self) -> None: + self.types: Dict[str, PMTypeBluePrint] = {} + self.locks: Dict[str, PMLockBluePrint] = {} + + def refresh(self, instrument: Any) -> None: + """Re-read every Type and Lock from the Parameter Manager. + + Works with a Proxy Instrument and with a local Parameter Manager: + both expose ``list_types``, ``get_type`` and ``list_locks``. + + :param instrument: the Parameter Manager whose Types and Locks to + read. + """ + self.types = { + type_name: instrument.get_type(type_name) + for type_name in instrument.list_types() + } + self.locks = dict(instrument.list_locks()) + + def apply_lock(self, path: str, lock: Optional[PMLockBluePrint]) -> None: + """Record the change a ``pm-lock-update`` Broadcast reports about + the Follower at ``path``. + + :param path: the Follower's path relative to the Parameter Manager. + :param lock: the Follower's :class:`PMLockBluePrint`, or ``None`` + when its Lock was removed (the entry is dropped then). + """ + if lock is None: + self.locks.pop(path, None) + else: + self.locks[path] = lock + + def apply_type(self, name: str, type_blueprint: Optional[PMTypeBluePrint]) -> None: + """Record the change a ``pm-type-update`` Broadcast reports about + the Type ``name``. + + :param name: the Type's name. + :param type_blueprint: the Type's :class:`PMTypeBluePrint`, or + ``None`` when the Type was removed (the entry is dropped then). + """ + if type_blueprint is None: + self.types.pop(name, None) + else: + self.types[name] = type_blueprint diff --git a/src/instrumentserver/gui/parameter_manager/panels.py b/src/instrumentserver/gui/parameter_manager/panels.py new file mode 100644 index 0000000..c52967d --- /dev/null +++ b/src/instrumentserver/gui/parameter_manager/panels.py @@ -0,0 +1,1407 @@ +"""The Parameter Manager GUI's panels and delegates: the gutter bands, the +Lock arm strip and Locks panel, and the Types tab.""" + +import logging +from typing import ( + Any, + Dict, + Iterable, + List, + Mapping, + Optional, + cast, +) + +from ... import QtCore, QtGui, QtWidgets +from ...blueprints import ( + PMLockBluePrint, + PMTypeBluePrint, +) +from .. import keepSmallHorizontally +from ..parameters import ParameterWidget +from .logic import ( + GUTTER_ROLE, + GUTTER_WIDTH, + LOCK_COLOUR, + EntryRow, + LockRow, + TypePalette, + also_types, + instances_of_type, + lock_button_tooltip, + lock_root, + relative_path, + type_entry_rows, +) + +logger = logging.getLogger(__name__) + + +# ----------------- Tints -------------------------------------------------------------- + + +class GutterDelegate(QtWidgets.QStyledItemDelegate): + """Draws the gutter bands of a row's stack of Types into the gutter + column: up to three vertical bands of equal width filling the cell, + one per Type of the stack, outermost first, left to right, in the + Types' ``bar`` colours. A row with no stack paints nothing beyond the + background.""" + + def __init__(self, parent: Optional[QtCore.QObject] = None) -> None: + super().__init__(parent) + # Owned by the Parameter Manager GUI and assigned after the view is + # built; the delegate only reads the Types' colours from it. + self.typePalette: Optional[TypePalette] = None + + def paint( + self, + painter: Optional[QtGui.QPainter], + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> None: + if painter is None: + return + opt = QtWidgets.QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + opt.text = "" + # the background first (alternating row or Type tint), then the bands + widget = opt.widget + style = widget.style() if widget is not None else QtWidgets.QApplication.style() + assert style is not None # an application always has a style + style.drawControl( + QtWidgets.QStyle.ControlElement.CE_ItemViewItem, opt, painter, widget + ) + if self.typePalette is None: + return + stack = index.data(GUTTER_ROLE) + if not stack: + return + bandWidth = opt.rect.width() / len(stack) + for band, type_name in enumerate(stack): + colour = self.typePalette.bar_colour(type_name) + if colour is None: + continue + painter.fillRect( + QtCore.QRectF( + opt.rect.x() + band * bandWidth, + opt.rect.y(), + bandWidth, + opt.rect.height(), + ), + colour, + ) + + def sizeHint( + self, + option: QtWidgets.QStyleOptionViewItem, + index: QtCore.QModelIndex, + ) -> QtCore.QSize: + return QtCore.QSize(GUTTER_WIDTH, super().sizeHint(option, index).height()) + + +# ----------------- Locks -------------------------------------------------------------- + + +def make_lock_button( + parent: QtWidgets.QWidget, locked: bool, target: Optional[str] = None +) -> QtWidgets.QPushButton: + """The lock/relock toggle button shared by the tree's per-row widget + (plan task 5.3) and the Locks panel (plan task 5.4): the lock icon and + the purple ``locked`` fill. ``target`` is the Target relative to the + Parameter Manager for the state tooltip; the tree's delegate passes + ``None`` and leaves the tooltip to + :meth:`.LocksController._update_row_lock_widget`.""" + button = QtWidgets.QPushButton(QtGui.QIcon(":/icons/lock.svg"), "", parent=parent) + button.setProperty("locked", locked) + button.setStyleSheet( + f'QPushButton[locked="true"] {{ background-color: {LOCK_COLOUR} }}' + ) + if target is not None: + button.setToolTip(lock_button_tooltip(locked, target)) + keepSmallHorizontally(button) + return button + + +class LockArmStrip(QtWidgets.QWidget): + """The arm strip under the toolbar while a Lock's Target is being + picked (plan task 5.3): a label naming the Follower, a line edit with + a completer over the ranked candidate paths, a Cancel button and an + error label for the Server's refusal text. + + Picking works three ways: a completion from the popup, Return with the + exact typed path (or the first completion the completer filters for + the typed text; a text that matches no candidate picks nothing), and + clicking a tree row — the last one is wired by the Parameter Manager + GUI, which owns the strip. Cancel is the button or Escape while the + strip or one of its children has focus.""" + + #: Signal(str) + #: Emitted when a Target was picked. The path is relative to the + #: Parameter Manager. + targetPicked = QtCore.Signal(str) + + #: Signal() + #: Emitted when the user cancels the pick (Cancel button or Escape). + cancelled = QtCore.Signal() + + def __init__(self, parent: Optional[QtWidgets.QWidget] = None) -> None: + super().__init__(parent) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.label = QtWidgets.QLabel(self) + + self.lineEdit = QtWidgets.QLineEdit(self) + self.lineEdit.setPlaceholderText("type part of the target path, or click a row") + + # the completer keeps the ranked candidate order (UnsortedModel) + # and filters it by what the user typed + self.completerModel = QtCore.QStringListModel(self) + self.completer = QtWidgets.QCompleter(self) + self.completer.setModel(self.completerModel) + self.completer.setFilterMode(QtCore.Qt.MatchFlag.MatchContains) + self.completer.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive) + self.completer.setModelSorting(QtWidgets.QCompleter.ModelSorting.UnsortedModel) + self.lineEdit.setCompleter(self.completer) + + self.cancelButton = QtWidgets.QPushButton("Cancel", self) + + self.errorLabel = QtWidgets.QLabel(self) + self.errorLabel.setStyleSheet( + "QLabel { background-color: red; color: white; font-weight: bold }" + ) + self.errorLabel.setVisible(False) + + layout.addWidget(self.label) + layout.addWidget(self.lineEdit, 1) + layout.addWidget(self.cancelButton) + layout.addWidget(self.errorLabel) + self.setLayout(layout) + + self.completer.activated[str].connect(self.targetPicked) + self.lineEdit.returnPressed.connect(self._on_return_pressed) + self.cancelButton.clicked.connect(self.cancelled) + + self.escShortcut = QtWidgets.QShortcut(QtGui.QKeySequence("Escape"), self) + self.escShortcut.setContext( + QtCore.Qt.ShortcutContext.WidgetWithChildrenShortcut + ) + self.escShortcut.activated.connect(self.cancelled) + + @QtCore.Slot() + def _on_return_pressed(self) -> None: + """Pick the exact typed path, or the first completion the + completer filters for the typed text (the mock's Enter picks the + first match); a text that matches no candidate picks nothing.""" + text = self.lineEdit.text().strip() + if not text: + return + if text in self.completerModel.stringList(): + self.targetPicked.emit(text) + return + # the completer's filtered matches for what was typed, in ranked + # order; its filter mode (MatchContains) and case sensitivity apply + self.completer.setCompletionPrefix(text) + completions = self.completer.completionModel() + if completions is not None and self.completer.completionCount() > 0: + first = completions.index(0, 0) + self.targetPicked.emit( + completions.data(first, QtCore.Qt.ItemDataRole.DisplayRole) + ) + + def arm(self, follower: str, candidates: List[str]) -> None: + """Arm the strip for the Follower at ``follower``: name it in the + label, load the ranked candidates into the completer, clear the + line edit and any error, show the strip and focus the line edit.""" + self.label.setText(f"Target for {follower}") + self.completerModel.setStringList(candidates) + self.lineEdit.clear() + self.clear_error() + self.setVisible(True) + self.lineEdit.setFocus() + + def show_error(self, text: str) -> None: + """Show the Server's error text on the error label.""" + self.errorLabel.setText(text) + self.errorLabel.setVisible(True) + + def clear_error(self) -> None: + """Hide and clear the error label (the next pick or cancel does + this).""" + self.errorLabel.setText("") + self.errorLabel.setVisible(False) + + def disarm(self) -> None: + """Hide the strip and clear it.""" + self.setVisible(False) + self.lineEdit.clear() + self.clear_error() + + +#: The Locks panel's default note (the mock's ``lockNote``, in glossary +#: words): shown until an action error or a skipped-Lock warning replaces +#: it. +LOCK_PANEL_NOTE = ( + "A Type Lock row — marked with its Type — holds one value for every " + "Instance of that Type. Unlock a Follower to let it keep its own " + "value, remove its Lock to take it out; the lock button on the Type " + "Lock row locks them all again." +) + +#: Starting pixel widths of the Locks panel's locks and buttons columns; +#: the user can drag both, and the value column takes the rest. +LOCK_PANEL_NAME_WIDTH = 180 +LOCK_PANEL_BUTTONS_WIDTH = 84 + +#: Data role under which a Locks panel row's path (relative to the +#: Parameter Manager) is stored on its first item, so the rows can be +#: found again after a rebuild. +LOCK_ROW_ROLE = cast("QtCore.Qt.ItemDataRole", QtCore.Qt.ItemDataRole.UserRole + 2) + + +class LocksPanel(QtWidgets.QWidget): + """The Locks panel right of the Parameter Manager tree (plan task 5.4; + the mock's locks panel): one root row per Target — the Type Lock + Targets first, labelled with their Type — with each Target's Followers + beneath it, recursively for chains. + + A Target row holds a value editor (a plain ``set`` on the Target); a + locked Follower row shows its value read-only, and every Follower row + that is not a Type Lock row carries the lock/relock toggle and the + remove button. A Type Lock row carries "lock all" and "remove rule". + The panel never talks to the Server itself: every action is emitted as + a signal — + ``toggleLockRequested``, ``removeLockRequested``, ``lockAllRequested``, + ``removeRuleRequested`` and ``lockSelectionRequested`` — and the + Parameter Manager GUI, which owns the panel, performs it and reports + errors and skipped Locks on the note label.""" + + #: Signal(str) + #: Emitted when the user presses a Follower row's lock/relock button; + #: the path is relative to the Parameter Manager. + toggleLockRequested = QtCore.Signal(str) + + #: Signal(str) + #: Emitted when the user presses a Follower row's remove button; the + #: path is relative to the Parameter Manager. + removeLockRequested = QtCore.Signal(str) + + #: Signal(str, str, str) + #: Emitted when the user presses a Type Lock row's "lock all" button: + #: the Type's name, the entry path, and the entry's stored Target + #: relative to the Parameter Manager. + lockAllRequested = QtCore.Signal(str, str, str) + + #: Signal(str, str) + #: Emitted when the user presses a Type Lock row's "remove rule" + #: button: the Type's name and the entry path. + removeRuleRequested = QtCore.Signal(str, str) + + #: Signal() + #: Emitted when the user presses "Lock selection to…". + lockSelectionRequested = QtCore.Signal() + + def __init__( + self, instrument_name: str, parent: Optional[QtWidgets.QWidget] = None + ) -> None: + super().__init__(parent) + self.instrument_name = instrument_name + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.model = QtGui.QStandardItemModel(0, 3, self) + self.model.setHorizontalHeaderLabels(["locks", "value", ""]) + + self.view = QtWidgets.QTreeView(self) + self.view.setModel(self.model) + self.view.setHeaderHidden(False) + self.view.setAlternatingRowColors(True) + self.view.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + # the user can drag the locks and buttons columns; the value + # column takes whatever width is left, so the values stay readable + header = self.view.header() + assert header is not None + header.setStretchLastSection(False) + header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.resizeSection(0, LOCK_PANEL_NAME_WIDTH) + header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Interactive) + header.resizeSection(2, LOCK_PANEL_BUTTONS_WIDTH) + + self.lockSelectionButton = QtWidgets.QPushButton("Lock selection to…", self) + self.selectedLabel = QtWidgets.QLabel(self) + self.selectedLabel.setText("no parameter selected") + + self.noteLabel = QtWidgets.QLabel(self) + self.noteLabel.setWordWrap(True) + self.noteLabel.setText(LOCK_PANEL_NOTE) + + layout.addWidget(self.view, 1) + selectionRow = QtWidgets.QHBoxLayout() + selectionRow.setContentsMargins(0, 0, 0, 0) + selectionRow.addWidget(self.lockSelectionButton) + selectionRow.addWidget(self.selectedLabel, 1) + layout.addLayout(selectionRow) + layout.addWidget(self.noteLabel) + self.setLayout(layout) + + # The widgets of every panel row, keyed by the row path (relative + # to the Parameter Manager); :meth:`refresh_values` re-reads the + # values without a rebuild. + self.rowWidgets: Dict[str, Dict[str, Any]] = {} + + self.lockSelectionButton.clicked.connect(self.lockSelectionRequested) + + def rebuild( + self, + rows: List[LockRow], + elements: Mapping[str, Any], + types: Mapping[str, PMTypeBluePrint], + locks: Mapping[str, PMLockBluePrint], + ) -> None: + """Rebuild every row from ``rows`` (see :func:`.build_lock_rows`). + + ``elements`` maps each row path to the row's parameter object (the + GUI resolves it through the Proxy or the local instrument); + ``types`` and ``locks`` are the client-side state the "lock all" + Target and the tooltips come from. Every row is expanded after the + rebuild; no collapsed state is kept.""" + self.model.removeRows(0, self.model.rowCount()) + self.rowWidgets = {} + root = self.model.invisibleRootItem() + assert root is not None # a model always has its root item + self._build_rows(rows, elements, types, locks, root) + self.view.expandAll() + + def refresh_values(self, paths: Iterable[str]) -> None: + """Re-read the value of every named row the panel holds: + editors through :meth:`ParameterWidget.setWidgetFromParameter`, + the read-only labels of locked rows through a fresh ``get``. A row + whose widget is gone — a rebuild replaced it — is skipped.""" + for path in paths: + entry = self.rowWidgets.get(path) + if entry is None: + continue + try: + if entry.get("editor") is not None: + entry["editor"].setWidgetFromParameter() + elif ( + entry.get("label") is not None and entry.get("element") is not None + ): + entry["label"].setText(str(entry["element"].get())) + except RuntimeError: + logger.debug( + f"Could not refresh the value of {path}. " + "Object is not being shown right now." + ) + + def show_value(self, paths: Iterable[str], value: Any) -> None: + """Show ``value`` on every named row the panel holds, without + asking the Server: the value a Broadcast carried. A row whose + widget is gone — a rebuild replaced it — is skipped.""" + for path in paths: + entry = self.rowWidgets.get(path) + if entry is None: + continue + try: + if entry.get("editor") is not None: + entry["editor"]._setMethod(value) + elif entry.get("label") is not None: + entry["label"].setText(str(value)) + except RuntimeError: + logger.debug( + f"Could not show the value of {path}. " + "Object is not being shown right now." + ) + + def show_error(self, text: str) -> None: + """Show an action error (the mock's ``lockError``) in red on the + note label.""" + self.noteLabel.setStyleSheet("QLabel { color: red }") + self.noteLabel.setText(text) + + def show_note(self, text: str) -> None: + """Show ``text`` on the note label in the normal colour (a + skipped-Lock warning, for example).""" + self.noteLabel.setStyleSheet("") + self.noteLabel.setText(text) + + def reset_note(self) -> None: + """Restore the default explanatory note.""" + self.show_note(LOCK_PANEL_NOTE) + + def _build_rows( + self, + rows: List[LockRow], + elements: Mapping[str, Any], + types: Mapping[str, PMTypeBluePrint], + locks: Mapping[str, PMLockBluePrint], + parent_item: QtGui.QStandardItem, + ) -> None: + for row in rows: + if row.type_locks: + label = f"[type: {', '.join(t for t, _ in row.type_locks)}] {row.path}" + else: + label = row.path + name_item = QtGui.QStandardItem(label) + name_item.setData(row.path, LOCK_ROW_ROLE) + value_item = QtGui.QStandardItem() + buttons_item = QtGui.QStandardItem() + parent_item.appendRow([name_item, value_item, buttons_item]) + self._build_row_widgets( + row, + elements.get(row.path), + types, + locks, + value_item, + buttons_item, + ) + self._build_rows(row.children, elements, types, locks, name_item) + + def _build_row_widgets( + self, + row: LockRow, + element: Any, + types: Mapping[str, PMTypeBluePrint], + locks: Mapping[str, PMLockBluePrint], + value_item: QtGui.QStandardItem, + buttons_item: QtGui.QStandardItem, + ) -> None: + """Build one row's value cell (a read-only label for a locked + Follower, a value editor for every other row with a parameter) and + its buttons cell (the Type Lock controls, or the Follower's toggle + and remove), and record the widgets in ``rowWidgets``.""" + path = row.path + entry: Dict[str, Any] = { + "element": element, + "editor": None, + "label": None, + "toggle": None, + "remove": None, + "lockAll": None, + "removeRule": None, + } + self.rowWidgets[path] = entry + + if row.lock is not None and row.lock.locked: + # a locked Follower reads its Target's value (D3) and refuses + # writes: a read-only label, like the mock's + target = relative_path(row.lock.target, self.instrument_name) + root = lock_root(path, locks, self.instrument_name) + label = QtWidgets.QLabel(self.view.viewport()) + value = "" + if element is not None: + try: + value = element.get() + except Exception as exc: + logger.debug(f"could not read the value of {path}: {exc}") + label.setText(str(value)) + label.setToolTip(f"locked to {target} — set the value on {root}") + self.view.setIndexWidget(self.model.indexFromItem(value_item), label) + entry["label"] = label + elif element is not None: + editor = ParameterWidget(element, self.view.viewport()) + if row.type_locks: + tooltip = ( + f"set the value — every Instance of " + f"{row.type_locks[0][0]} follows it" + ) + else: + tooltip = "set the Target value — every locked Follower follows it" + editor.setButton.setToolTip(tooltip) + self.view.setIndexWidget(self.model.indexFromItem(value_item), editor) + entry["editor"] = editor + + container: Optional[QtWidgets.QWidget] = None + if row.type_locks: + # the Type Lock controls; like the mock, the first (Type, + # entry) pair acts when several share the Target + type_name, entry_path = row.type_locks[0] + blueprint = types.get(type_name) + spec = ( + blueprint.parameters.get(entry_path, {}) + if blueprint is not None + else {} + ) + stored = spec.get("target") + stored_relative = ( + relative_path(stored, self.instrument_name) + if stored is not None + else path + ) + container = QtWidgets.QWidget(self.view.viewport()) + buttons_layout = QtWidgets.QHBoxLayout(container) + buttons_layout.setContentsMargins(0, 0, 0, 0) + lock_all = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/lock.svg"), "", parent=container + ) + lock_all.setToolTip(f"lock every Instance of {type_name} to this again") + keepSmallHorizontally(lock_all) + lock_all.pressed.connect( + lambda: self.lockAllRequested.emit( + type_name, entry_path, stored_relative + ) + ) + remove_rule = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), "", parent=container + ) + remove_rule.setStyleSheet("QPushButton { background-color: salmon }") + remove_rule.setToolTip( + "remove the Type Lock — the Instances' Locks stay until " + "removed one by one" + ) + keepSmallHorizontally(remove_rule) + remove_rule.pressed.connect( + lambda: self.removeRuleRequested.emit(type_name, entry_path) + ) + buttons_layout.addWidget(lock_all) + buttons_layout.addWidget(remove_rule) + entry["lockAll"] = lock_all + entry["removeRule"] = remove_rule + elif row.lock is not None: + # a Follower's lock/relock toggle and remove button + container = QtWidgets.QWidget(self.view.viewport()) + buttons_layout = QtWidgets.QHBoxLayout(container) + buttons_layout.setContentsMargins(0, 0, 0, 0) + target = relative_path(row.lock.target, self.instrument_name) + toggle = make_lock_button(container, row.lock.locked, target) + toggle.pressed.connect( + lambda follower=path: self.toggleLockRequested.emit(follower) + ) + remove = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), "", parent=container + ) + remove.setStyleSheet("QPushButton { background-color: salmon }") + remove.setToolTip(f"remove the Lock — {path} keeps its own value") + keepSmallHorizontally(remove) + remove.pressed.connect( + lambda follower=path: self.removeLockRequested.emit(follower) + ) + buttons_layout.addWidget(toggle) + buttons_layout.addWidget(remove) + entry["toggle"] = toggle + entry["remove"] = remove + if container is not None: + self.view.setIndexWidget(self.model.indexFromItem(buttons_item), container) + + +# ----------------- Types tab ---------------------------------------------------------- + + +#: Fixed pixel widths of the entries pane's unit, "locked to" and default +#: columns (the mock's 60/200/252 trio). +ENTRIES_UNIT_WIDTH = 60 +ENTRIES_LOCK_WIDTH = 210 +ENTRIES_DEFAULT_WIDTH = 250 + +#: Fixed pixel widths of the instances pane's parameter-count, "also" and +#: button columns (the mock's 150/160/110 trio). +INSTANCES_COUNT_WIDTH = 110 +INSTANCES_ALSO_WIDTH = 160 +INSTANCES_BUTTON_WIDTH = 80 + + +class TypesPane(QtWidgets.QWidget): + """The Types tab (plan task 5.5; the mock's Types view): three panes + around the selected Type. + + Left: the list of Types — name, number of Instances and number of + effective parameters, each row tinted with the Type's colour — and the + New type strip. Right, above: the entries of the selected Type as a + tree. Own entries carry an editable default (Return or the set button + commits), a Remove button and the Type Lock toggle in the "locked to" + column, with a re-target button and the Target's path while locked. + Entries from Nested Types render read-only with "defined by ". + Submodule rows show ``type: `` in the "locked to" column and, for + the selected Type's own Nested Types, a Remove button. Beneath the + tree run the "Add to type" and "Nested type" strips and a note line. + Right, below: the Instances of the selected Type — name, parameter + count, the other Types the Instance also carries and a Show button — + with the New instance strip and a note line. + + The pane never talks to the Server: every action is emitted as a + signal — ``addTypeRequested``, ``addEntryRequested``, + ``removeEntryRequested``, ``setDefaultRequested``, + ``toggleTypeLockRequested``, ``retargetTypeLockRequested``, + ``addNestedRequested``, ``removeNestedRequested``, + ``addInstanceRequested`` and ``showInstanceRequested`` — and + :class:`.ParameterManagerGui`, which owns the pane, performs it and + reports errors and skipped Locks on the pane's note labels. + """ + + #: Signal(str) + #: Emitted when the user presses the New type strip's Add button; + #: the name is trimmed and not empty. + addTypeRequested = QtCore.Signal(str) + + #: Signal(str) + #: Emitted when the selected Type changes (a row click, or a rebuild + #: that had to pick one). + typeSelected = QtCore.Signal(str) + + #: Signal(str, str, str, str) + #: Emitted when the user presses "Add to type": the Type's name, the + #: entry path, the default text and the unit. + addEntryRequested = QtCore.Signal(str, str, str, str) + + #: Signal(str, str) + #: Emitted when the user presses an own entry's Remove button: the + #: Type's name and the entry path. + removeEntryRequested = QtCore.Signal(str, str) + + #: Signal(str, str, str) + #: Emitted when the user commits an own entry's default editor + #: (Return or the set button): the Type's name, the entry path and + #: the editor's text. + setDefaultRequested = QtCore.Signal(str, str, str) + + #: Signal(str, str) + #: Emitted when the user presses a Type Lock toggle: the Type's name + #: and the entry path. The GUI locks or unlocks from the entry's + #: stored Target. + toggleTypeLockRequested = QtCore.Signal(str, str) + + #: Signal(str, str) + #: Emitted when the user presses a locked entry's re-target button: + #: the Type's name and the entry path. + retargetTypeLockRequested = QtCore.Signal(str, str) + + #: Signal(str, str, str) + #: Emitted when the user presses "Add nested type": the Type's name, + #: the submodule and the Nested Type's name. + addNestedRequested = QtCore.Signal(str, str, str) + + #: Signal(str, str) + #: Emitted when the user presses an own Nested Type's Remove button: + #: the Type's name and the submodule. + removeNestedRequested = QtCore.Signal(str, str) + + #: Signal(str, str) + #: Emitted when the user presses the New instance strip's button: the + #: Type's name and the Instance's name. + addInstanceRequested = QtCore.Signal(str, str) + + #: Signal(str, str) + #: Emitted when the user presses an instance row's Show button: the + #: Type's name and the Instance's name. + showInstanceRequested = QtCore.Signal(str, str) + + def __init__( + self, instrument_name: str, parent: Optional[QtWidgets.QWidget] = None + ) -> None: + super().__init__(parent) + self.instrument_name = instrument_name + + # the selected Type, and one requested while the Server call that + # creates it is still in flight (honoured on the next rebuild) + self.selectedType: Optional[str] = None + self.requestedType: Optional[str] = None + # guards the selection slot against the rebuild's own index changes + self._building = False + + # the widgets of the entries rows, keyed by row path; and the Show + # buttons of the instance rows, keyed by instance path + self.entryWidgets: Dict[str, Dict[str, Any]] = {} + self.showButtons: Dict[str, QtWidgets.QPushButton] = {} + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + self.splitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal, self) + + # -- left: the list of Types and the New type strip + typeListPane = QtWidgets.QWidget(self.splitter) + typeListLayout = QtWidgets.QVBoxLayout(typeListPane) + typeListLayout.setContentsMargins(0, 0, 0, 0) + + self.typeModel = QtGui.QStandardItemModel(0, 3, self) + self.typeModel.setHorizontalHeaderLabels(["type", "instances", "parameters"]) + self.typeList = QtWidgets.QTreeView(typeListPane) + self.typeList.setModel(self.typeModel) + self.typeList.setRootIsDecorated(False) + self.typeList.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.typeList.setAlternatingRowColors(True) + typeHeader = self.typeList.header() + assert typeHeader is not None # a QTreeView always has a header + typeHeader.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) + for column, width in ((1, 70), (2, 90)): + typeHeader.setSectionResizeMode( + column, QtWidgets.QHeaderView.ResizeMode.Fixed + ) + typeHeader.resizeSection(column, width) + + typeStrip = QtWidgets.QHBoxLayout() + typeStrip.setContentsMargins(0, 0, 0, 0) + typeStrip.addWidget(QtWidgets.QLabel("New type:")) + self.newTypeEdit = QtWidgets.QLineEdit(typeListPane) + self.newTypeEdit.setPlaceholderText("cavity") + self.addTypeButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), " Add" + ) + keepSmallHorizontally(self.addTypeButton) + typeStrip.addWidget(self.newTypeEdit, 1) + typeStrip.addWidget(self.addTypeButton) + self.typeNote = QtWidgets.QLabel(typeListPane) + + typeListLayout.addWidget(self.typeList, 1) + typeListLayout.addLayout(typeStrip) + typeListLayout.addWidget(self.typeNote) + + # -- right: the entries pane above the instances pane + rightPane = QtWidgets.QSplitter(QtCore.Qt.Orientation.Vertical, self.splitter) + + entriesPane = QtWidgets.QWidget(rightPane) + entriesLayout = QtWidgets.QVBoxLayout(entriesPane) + entriesLayout.setContentsMargins(0, 0, 0, 0) + + self.entriesLabel = QtWidgets.QLabel(entriesPane) + self.entriesModel = QtGui.QStandardItemModel(0, 4, self) + self.entriesModel.setHorizontalHeaderLabels( + ["parameter", "unit", "locked to", "default"] + ) + self.entriesView = QtWidgets.QTreeView(entriesPane) + self.entriesView.setModel(self.entriesModel) + self.entriesView.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.entriesView.setAlternatingRowColors(True) + entriesHeader = self.entriesView.header() + assert entriesHeader is not None # a QTreeView always has a header + entriesHeader.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeMode.Stretch) + for column, width in ( + (1, ENTRIES_UNIT_WIDTH), + (2, ENTRIES_LOCK_WIDTH), + (3, ENTRIES_DEFAULT_WIDTH), + ): + entriesHeader.setSectionResizeMode( + column, QtWidgets.QHeaderView.ResizeMode.Interactive + ) + entriesHeader.resizeSection(column, width) + + entryStrip = QtWidgets.QHBoxLayout() + entryStrip.setContentsMargins(0, 0, 0, 0) + entryStrip.addWidget(QtWidgets.QLabel("Name:")) + self.entryNameEdit = QtWidgets.QLineEdit(entriesPane) + self.entryNameEdit.setPlaceholderText("pulses.pi.drag_multiplier") + entryStrip.addWidget(self.entryNameEdit, 2) + entryStrip.addWidget(QtWidgets.QLabel("Default:")) + self.entryDefaultEdit = QtWidgets.QLineEdit(entriesPane) + entryStrip.addWidget(self.entryDefaultEdit, 1) + entryStrip.addWidget(QtWidgets.QLabel("Unit:")) + self.entryUnitEdit = QtWidgets.QLineEdit(entriesPane) + entryStrip.addWidget(self.entryUnitEdit, 1) + self.addEntryButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), "Add to type" + ) + keepSmallHorizontally(self.addEntryButton) + entryStrip.addWidget(self.addEntryButton) + + nestedStrip = QtWidgets.QHBoxLayout() + nestedStrip.setContentsMargins(0, 0, 0, 0) + nestedStrip.addWidget(QtWidgets.QLabel("Nested type:")) + self.nestedTypeCombo = QtWidgets.QComboBox(entriesPane) + nestedStrip.addWidget(self.nestedTypeCombo, 2) + nestedStrip.addWidget(QtWidgets.QLabel("at:")) + self.nestedAtEdit = QtWidgets.QLineEdit(entriesPane) + self.nestedAtEdit.setPlaceholderText("readout") + nestedStrip.addWidget(self.nestedAtEdit, 1) + self.addNestedButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), "Add nested type" + ) + self.addNestedButton.setToolTip("require another Type at that submodule") + keepSmallHorizontally(self.addNestedButton) + nestedStrip.addWidget(self.addNestedButton) + + self.entriesNote = QtWidgets.QLabel(entriesPane) + self.entriesNote.setWordWrap(True) + + entriesLayout.addWidget(self.entriesLabel) + entriesLayout.addWidget(self.entriesView, 1) + entriesLayout.addLayout(entryStrip) + entriesLayout.addLayout(nestedStrip) + entriesLayout.addWidget(self.entriesNote) + + instancesPane = QtWidgets.QWidget(rightPane) + instancesLayout = QtWidgets.QVBoxLayout(instancesPane) + instancesLayout.setContentsMargins(0, 0, 0, 0) + + self.instancesLabel = QtWidgets.QLabel(instancesPane) + self.instancesModel = QtGui.QStandardItemModel(0, 4, self) + self.instancesModel.setHorizontalHeaderLabels( + ["instance", "parameters", "also", ""] + ) + self.instancesView = QtWidgets.QTreeView(instancesPane) + self.instancesView.setModel(self.instancesModel) + self.instancesView.setRootIsDecorated(False) + self.instancesView.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + self.instancesView.setAlternatingRowColors(True) + instancesHeader = self.instancesView.header() + assert instancesHeader is not None # a QTreeView always has a header + instancesHeader.setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.Stretch + ) + for column, width in ( + (1, INSTANCES_COUNT_WIDTH), + (2, INSTANCES_ALSO_WIDTH), + (3, INSTANCES_BUTTON_WIDTH), + ): + instancesHeader.setSectionResizeMode( + column, QtWidgets.QHeaderView.ResizeMode.Fixed + ) + instancesHeader.resizeSection(column, width) + + instanceStrip = QtWidgets.QHBoxLayout() + instanceStrip.setContentsMargins(0, 0, 0, 0) + instanceStrip.addWidget(QtWidgets.QLabel("New instance:")) + self.newInstanceEdit = QtWidgets.QLineEdit(instancesPane) + self.newInstanceEdit.setPlaceholderText("q04") + instanceStrip.addWidget(self.newInstanceEdit, 1) + self.addInstanceButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), "Add instance" + ) + keepSmallHorizontally(self.addInstanceButton) + instanceStrip.addWidget(self.addInstanceButton) + self.instancesNote = QtWidgets.QLabel(instancesPane) + + instancesLayout.addWidget(self.instancesLabel) + instancesLayout.addWidget(self.instancesView, 1) + instancesLayout.addLayout(instanceStrip) + instancesLayout.addWidget(self.instancesNote) + + rightPane.addWidget(entriesPane) + rightPane.addWidget(instancesPane) + rightPane.setStretchFactor(0, 3) + rightPane.setStretchFactor(1, 2) + self.splitter.addWidget(typeListPane) + self.splitter.addWidget(rightPane) + self.splitter.setStretchFactor(0, 2) + self.splitter.setStretchFactor(1, 5) + layout.addWidget(self.splitter) + self.setLayout(layout) + + self.addTypeButton.clicked.connect(self._request_add_type) + self.newTypeEdit.returnPressed.connect(self.addTypeButton.click) + self.addEntryButton.clicked.connect(self._request_add_entry) + for edit in (self.entryNameEdit, self.entryDefaultEdit, self.entryUnitEdit): + edit.returnPressed.connect(self.addEntryButton.click) + self.addNestedButton.clicked.connect(self._request_add_nested) + self.nestedAtEdit.returnPressed.connect(self.addNestedButton.click) + self.addInstanceButton.clicked.connect(self._request_add_instance) + self.newInstanceEdit.returnPressed.connect(self.addInstanceButton.click) + typeSelection = self.typeList.selectionModel() + assert typeSelection is not None # set together with the model + typeSelection.currentChanged.connect(self._on_type_selected) + + # ------------------------------------------------------------------ + # rebuilds (plan task 5.5, readings 2-4, 7-8) + # ------------------------------------------------------------------ + + def select_type(self, name: str) -> None: + """Request the selection of the Type ``name``: honoured on the + next rebuild, once the Type is in the state the pane rebuilds + from. Used after the Server call that creates the Type.""" + self.requestedType = name + + def rebuild( + self, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + """Rebuild the three panes from the client-side state (plan task + 5.5): the Type list from ``types`` with Instances counted over + ``parameters``, the entries and Instances panes from the selected + Type, and every row tinted with ``palette``.""" + self._rebuild_type_list(types, parameters, palette) + self._rebuild_selected_panes(types, parameters, palette) + + def _rebuild_type_list( + self, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + names = list(types) + selection_changed = False + if self.requestedType is not None and self.requestedType in types: + selection_changed = self.selectedType != self.requestedType + self.selectedType = self.requestedType + self.requestedType = None + elif self.selectedType not in types: + # the first Type is selected when none is; the selection is + # dropped when the Type is gone + selection_changed = self.selectedType != (names[0] if names else None) + self.selectedType = names[0] if names else None + self.typeModel.removeRows(0, self.typeModel.rowCount()) + current_row = -1 + for row, name in enumerate(names): + count = len(instances_of_type(name, types, parameters)) + name_item = QtGui.QStandardItem(name) + instances_item = QtGui.QStandardItem(str(count)) + params_item = QtGui.QStandardItem(str(len(types[name].effective))) + self.typeModel.appendRow([name_item, instances_item, params_item]) + colours = palette.colours(name) + if colours is not None: + for item in (name_item, instances_item, params_item): + item.setData(colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole) + if name == self.selectedType: + current_row = row + self._building = True + if current_row >= 0: + self.typeList.setCurrentIndex(self.typeModel.index(current_row, 0)) + else: + self.typeList.setCurrentIndex(QtCore.QModelIndex()) + self._building = False + if selection_changed and self.selectedType is not None: + self.typeSelected.emit(self.selectedType) + + def _rebuild_selected_panes( + self, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + selected = self.selectedType or "" + # with no Type selected the labels keep no trailing space and the + # three strips are disabled — their actions all need a Type + # (plan task 5.6) + has_type = bool(selected) + self.entriesLabel.setText( + f"parameters of {selected}" if has_type else "parameters" + ) + self.instancesLabel.setText( + f"instances of {selected}" if has_type else "instances" + ) + self.addEntryButton.setEnabled(has_type) + self.addNestedButton.setEnabled(has_type) + self.addInstanceButton.setEnabled(has_type) + self._rebuild_nested_combo(selected, types) + self._rebuild_entries(selected, types, palette) + self._rebuild_instances(selected, types, parameters, palette) + + def _rebuild_nested_combo( + self, selected: str, types: Mapping[str, PMTypeBluePrint] + ) -> None: + self.nestedTypeCombo.clear() + self.nestedTypeCombo.addItems( + sorted(name for name in types if name != selected) + ) + + def _clear_index_widgets( + self, parent: Optional[QtGui.QStandardItem] = None + ) -> None: + """Delete the row widgets the entries view still hosts, so a + rebuild does not leave the old ones behind.""" + if parent is None: + parent = self.entriesModel.invisibleRootItem() + assert parent is not None # a model always has its root item + for row in range(parent.rowCount()): + for column in range(parent.columnCount()): + child = parent.child(row, column) + if child is None: + continue + widget = self.entriesView.indexWidget( + self.entriesModel.indexFromItem(child) + ) + if widget is not None: + widget.deleteLater() + first = parent.child(row, 0) + if first is not None and first.hasChildren(): + self._clear_index_widgets(first) + + def _entry_tint_type(self, rows: List[EntryRow], index: int, selected: str) -> str: + """The Type whose tint an entries row shows: an entry row its + defining Type, a Nested Type row the Type required there, and a + structural submodule row the defining Type of the first entry + below it (the selected Type when that entry is own; the mock's + ``tintsFor(inc ? inc.type : (p.from || selType))``).""" + row = rows[index] + if row.kind == "entry": + return row.from_type or selected + if row.nested_type is not None: + return row.nested_type + for later in rows[index + 1 :]: + if later.kind == "entry": + return later.from_type or selected + return selected + + def _rebuild_entries( + self, selected: str, types: Mapping[str, PMTypeBluePrint], palette: TypePalette + ) -> None: + self._clear_index_widgets() + self.entriesModel.removeRows(0, self.entriesModel.rowCount()) + self.entryWidgets = {} + blueprint = types.get(selected) + if selected is None or blueprint is None: + self.entriesView.expandAll() + return + rows = type_entry_rows(selected, types, self.instrument_name) + items_by_path: Dict[str, QtGui.QStandardItem] = {} + for index, row in enumerate(rows): + path = row.path + parent_item = ( + items_by_path[path.rsplit(".", 1)[0]] + if "." in path + else self.entriesModel.invisibleRootItem() + ) + assert parent_item is not None # a model always has its root item + colours = palette.colours(self._entry_tint_type(rows, index, selected)) + name_item = QtGui.QStandardItem(path.split(".")[-1]) + unit_item = QtGui.QStandardItem("" if row.kind == "submodule" else row.unit) + lock_item = QtGui.QStandardItem() + default_item = QtGui.QStandardItem() + parent_item.appendRow([name_item, unit_item, lock_item, default_item]) + items_by_path[path] = name_item + if colours is not None: + for item in (name_item, unit_item, lock_item, default_item): + item.setData(colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole) + entry: Dict[str, Any] = { + "editor": None, + "set": None, + "remove": None, + "toggle": None, + "retarget": None, + "targetLabel": None, + "definedBy": None, + "removeNested": None, + } + self.entryWidgets[path] = entry + if row.kind == "submodule": + self._build_submodule_row( + selected, blueprint, row, lock_item, default_item, entry + ) + else: + self._build_entry_row(selected, row, lock_item, default_item, entry) + self.entriesView.expandAll() + + def _build_submodule_row( + self, + selected: str, + blueprint: PMTypeBluePrint, + row: EntryRow, + lock_item: QtGui.QStandardItem, + default_item: QtGui.QStandardItem, + entry: Dict[str, Any], + ) -> None: + if row.nested_type is not None: + lock_item.setText(f"type: {row.nested_type}") + if row.path in blueprint.nested: + # only the selected Type's OWN Nested Types are removable + nested = blueprint.nested[row.path] + remove = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), + "", + parent=self.entriesView.viewport(), + ) + remove.setStyleSheet("QPushButton { background-color: salmon }") + remove.setToolTip( + f"stop requiring {nested} here — Instances keep the parameters" + ) + keepSmallHorizontally(remove) + remove.pressed.connect( + lambda type_name=selected, submodule=row.path: ( + self.removeNestedRequested.emit(type_name, submodule) + ) + ) + self.entriesView.setIndexWidget( + self.entriesModel.indexFromItem(default_item), remove + ) + entry["removeNested"] = remove + + def _build_entry_row( + self, + selected: str, + row: EntryRow, + lock_item: QtGui.QStandardItem, + default_item: QtGui.QStandardItem, + entry: Dict[str, Any], + ) -> None: + if row.own: + self._build_own_entry_cells(selected, row, lock_item, default_item, entry) + else: + self._build_nested_entry_cells(selected, row, default_item, entry) + + def _build_own_entry_cells( + self, + selected: str, + row: EntryRow, + lock_item: QtGui.QStandardItem, + default_item: QtGui.QStandardItem, + entry: Dict[str, Any], + ) -> None: + # the "locked to" column: the Type Lock toggle, and while the entry + # is locked the re-target button and the Target's relative path + locked = row.target is not None + lock_container = QtWidgets.QWidget(self.entriesView.viewport()) + lock_layout = QtWidgets.QHBoxLayout(lock_container) + lock_layout.setContentsMargins(0, 0, 0, 0) + toggle = make_lock_button(lock_container, locked) + if locked: + toggle.setToolTip( + f"locked to {row.target} — unlock and every Instance of " + f"{selected} goes back to its own value" + ) + else: + toggle.setToolTip( + f"lock — _globals.{selected}.{row.path} is created to hold the " + f"value, and every Instance of {selected} follows it" + ) + toggle.pressed.connect( + lambda type_name=selected, path=row.path: self.toggleTypeLockRequested.emit( + type_name, path + ) + ) + lock_layout.addWidget(toggle) + entry["toggle"] = toggle + if locked: + retarget = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/set.svg"), "", parent=lock_container + ) + retarget.setToolTip( + f"lock every Instance of {selected} to another Target — " + "pick one in the parameter tree" + ) + keepSmallHorizontally(retarget) + retarget.pressed.connect( + lambda type_name=selected, path=row.path: ( + self.retargetTypeLockRequested.emit(type_name, path) + ) + ) + lock_layout.addWidget(retarget) + entry["retarget"] = retarget + target_label = QtWidgets.QLabel(row.target, parent=lock_container) + target_label.setToolTip( + f"{row.target} — followed by every Instance of {selected}" + ) + lock_layout.addWidget(target_label, 1) + entry["targetLabel"] = target_label + self.entriesView.setIndexWidget( + self.entriesModel.indexFromItem(lock_item), lock_container + ) + + # the default column: the editable default with its set button and + # the entry's Remove button + editor_container = QtWidgets.QWidget(self.entriesView.viewport()) + editor_layout = QtWidgets.QHBoxLayout(editor_container) + editor_layout.setContentsMargins(0, 0, 0, 0) + editor = QtWidgets.QLineEdit(editor_container) + editor.setText("" if row.default is None else str(row.default)) + editor.setPlaceholderText("no default") + set_button = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/set.svg"), "", parent=editor_container + ) + keepSmallHorizontally(set_button) + set_button.pressed.connect( + lambda: self.setDefaultRequested.emit(selected, row.path, editor.text()) + ) + editor.returnPressed.connect(set_button.click) + remove = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), "", parent=editor_container + ) + remove.setStyleSheet("QPushButton { background-color: salmon }") + remove.setToolTip( + "remove from the Type only — Instances keep the parameter " + "and lose the Type tint" + ) + keepSmallHorizontally(remove) + remove.pressed.connect( + lambda type_name=selected, path=row.path: self.removeEntryRequested.emit( + type_name, path + ) + ) + editor_layout.addWidget(editor, 1) + editor_layout.addWidget(set_button) + editor_layout.addWidget(remove) + self.entriesView.setIndexWidget( + self.entriesModel.indexFromItem(default_item), editor_container + ) + entry["editor"] = editor + entry["set"] = set_button + entry["remove"] = remove + + def _build_nested_entry_cells( + self, + selected: str, + row: EntryRow, + default_item: QtGui.QStandardItem, + entry: Dict[str, Any], + ) -> None: + # read-only default text, and "defined by " where the Remove + # button of an own entry would sit + container = QtWidgets.QWidget(self.entriesView.viewport()) + layout = QtWidgets.QHBoxLayout(container) + layout.setContentsMargins(0, 0, 0, 0) + default_label = QtWidgets.QLabel( + "" if row.default is None else str(row.default), parent=container + ) + layout.addWidget(default_label, 1) + defined_by = QtWidgets.QLabel(f"defined by {row.from_type}", parent=container) + defined_by.setToolTip(f"defined by {row.from_type} — change the default there") + layout.addWidget(defined_by) + self.entriesView.setIndexWidget( + self.entriesModel.indexFromItem(default_item), container + ) + entry["definedBy"] = defined_by + + def _rebuild_instances( + self, + selected: str, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + self.instancesModel.removeRows(0, self.instancesModel.rowCount()) + self.showButtons = {} + if not selected: + return + colours = palette.colours(selected) + for instance in instances_of_type(selected, types, parameters): + count = sum(1 for path in parameters if path.startswith(f"{instance}.")) + also = [ + type_name + for type_name in also_types(instance, types, parameters) + if type_name != selected + ] + name_item = QtGui.QStandardItem(instance) + count_item = QtGui.QStandardItem(f"{count} parameters") + also_item = QtGui.QStandardItem(f"also {', '.join(also)}" if also else "") + button_item = QtGui.QStandardItem() + self.instancesModel.appendRow( + [name_item, count_item, also_item, button_item] + ) + if colours is not None: + for item in (name_item, count_item, also_item, button_item): + item.setData(colours["tint"], QtCore.Qt.ItemDataRole.BackgroundRole) + show = QtWidgets.QPushButton("Show", parent=self.instancesView.viewport()) + show.setToolTip("show in the parameter tree") + show.pressed.connect( + lambda type_name=selected, node=instance: ( + self.showInstanceRequested.emit(type_name, node) + ) + ) + self.instancesView.setIndexWidget( + self.instancesModel.indexFromItem(button_item), show + ) + self.showButtons[instance] = show + + # ------------------------------------------------------------------ + # selection and strip requests (plan task 5.5, readings 3 and 5) + # ------------------------------------------------------------------ + + @QtCore.Slot(QtCore.QModelIndex, QtCore.QModelIndex) + def _on_type_selected( + self, current: QtCore.QModelIndex, previous: QtCore.QModelIndex + ) -> None: + """A row click selects the Type for the other two panes.""" + if self._building or not current.isValid(): + return + name = self.typeModel.item(current.row(), 0) + if name is None or name.text() == self.selectedType: + return + self.selectedType = name.text() + self.requestedType = None + self.typeSelected.emit(name.text()) + + def refresh_selected_panes( + self, + types: Mapping[str, PMTypeBluePrint], + parameters: Mapping[str, str], + palette: TypePalette, + ) -> None: + """Rebuild only the entries and Instances panes, keeping the Type + list as it is: the slot of a user selection.""" + self._rebuild_selected_panes(types, parameters, palette) + + @QtCore.Slot() + def _request_add_type(self) -> None: + name = self.newTypeEdit.text().strip() + if not name: + self.show_type_error("Name must not be empty.") + return + self.addTypeRequested.emit(name) + + @QtCore.Slot() + def _request_add_entry(self) -> None: + path = self.entryNameEdit.text().strip() + if not path: + self.show_entries_error("Name must not be empty.") + return + if self.selectedType is None: + return + # the unit is stripped: matching compares units exactly (D12), and + # a trailing space would make the Type match no Instance + self.addEntryRequested.emit( + self.selectedType, + path, + self.entryDefaultEdit.text(), + self.entryUnitEdit.text().strip(), + ) + + @QtCore.Slot() + def _request_add_nested(self) -> None: + submodule = self.nestedAtEdit.text().strip() + if not submodule: + self.show_entries_error("Submodule must not be empty.") + return + if self.selectedType is None: + return + self.addNestedRequested.emit( + self.selectedType, submodule, self.nestedTypeCombo.currentText() + ) + + @QtCore.Slot() + def _request_add_instance(self) -> None: + name = self.newInstanceEdit.text().strip() + if not name: + self.show_instances_error("Name must not be empty.") + return + if self.selectedType is None: + return + self.addInstanceRequested.emit(self.selectedType, name) + + # ------------------------------------------------------------------ + # note lines (plan task 5.5, reading 5) + # ------------------------------------------------------------------ + + def show_type_error(self, text: str) -> None: + """Show an action error in red on the New type strip's note.""" + self.typeNote.setStyleSheet("QLabel { color: red }") + self.typeNote.setText(text) + + def reset_type_note(self) -> None: + """Restore the New type strip's default note (empty).""" + self.typeNote.setStyleSheet("") + self.typeNote.setText("") + + def show_entries_error(self, text: str) -> None: + """Show an action error in red on the entries pane's note.""" + self.entriesNote.setStyleSheet("QLabel { color: red }") + self.entriesNote.setText(text) + + def show_entries_note(self, text: str) -> None: + """Show ``text`` on the entries pane's note in the normal colour + (a skipped-Lock warning, for example).""" + self.entriesNote.setStyleSheet("") + self.entriesNote.setText(text) + + def reset_entries_note(self) -> None: + """Restore the entries pane's default note (empty).""" + self.entriesNote.setStyleSheet("") + self.entriesNote.setText("") + + def show_instances_error(self, text: str) -> None: + """Show an action error in red on the instances pane's note.""" + self.instancesNote.setStyleSheet("QLabel { color: red }") + self.instancesNote.setText(text) + + def reset_instances_note(self) -> None: + """Restore the instances pane's default note (empty).""" + self.instancesNote.setStyleSheet("") + self.instancesNote.setText("") diff --git a/src/instrumentserver/gui/parameter_manager/widget.py b/src/instrumentserver/gui/parameter_manager/widget.py new file mode 100644 index 0000000..f2f4b5c --- /dev/null +++ b/src/instrumentserver/gui/parameter_manager/widget.py @@ -0,0 +1,1529 @@ +"""The Parameter Manager widget (:class:`ParameterManagerGui`) and the +model, tree view and create form it is built from.""" + +import logging +from typing import ( + Any, + Dict, + List, + Optional, + Tuple, + Union, + cast, +) + +from ... import QtCore, QtGui, QtWidgets +from ...blueprints import ( + PARAMETER_DELETION, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ParameterBroadcastBluePrint, + PMLockBluePrint, + PMTypeBluePrint, +) +from ...client import ProxyInstrument +from ...helpers import nestedAttributeFromString +from ...params import ( + ParameterManager, + ParameterTypes, + parameterTypes, + paramTypeFromName, +) +from .. import keepSmallHorizontally +from ..base_instrument import ItemBase +from ..instruments import ( + InstrumentParameters, + ModelParameters, + ParameterDelegate, + ParametersTreeView, +) +from ..parameters import ParameterWidget +from .logic import ( + GUTTER_COLUMN, + GUTTER_ROLE, + GUTTER_WIDTH, + LOCK_COLUMN, + LOCK_COLUMN_WIDTH, + Claim, + PMState, + TypePalette, + build_lock_rows, + compute_claims, + followers_reaching, + is_dark_theme, + lock_button_tooltip, + lock_column_text, + lock_row_paths, + parse_default_text, + rank_lock_targets, + relative_path, +) +from .panels import ( + GutterDelegate, + LockArmStrip, + LocksPanel, + TypesPane, + make_lock_button, +) + +logger = logging.getLogger(__name__) + + +class AddParameterWidget(QtWidgets.QWidget): + """A widget that allows parameter creation. + + :param parent: parent widget + :param typeInput: if ``True``, add input fields for creating a value + validator. + """ + + #: Signal(str, str, str, ParameterTypes, str) + newParamRequested = QtCore.Signal(str, str, str, ParameterTypes, str) + + #: Signal(str) + invalidParamRequested = QtCore.Signal(str) + + def __init__( + self, parent: Optional[QtWidgets.QWidget] = None, typeInput: bool = False + ) -> None: + super().__init__(parent) + + self.typeInput = typeInput + + layout = QtWidgets.QGridLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.nameEdit = QtWidgets.QLineEdit(self) + lbl = QtWidgets.QLabel("Name:") + lbl.setAlignment( + cast( + "QtCore.Qt.Alignment", + QtCore.Qt.AlignmentFlag.AlignRight + | QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + ) + layout.addWidget(lbl, 0, 0) + layout.addWidget(self.nameEdit, 0, 1) + + self.valueEdit = QtWidgets.QLineEdit(self) + lbl = QtWidgets.QLabel("Value:") + lbl.setAlignment( + cast( + "QtCore.Qt.Alignment", + QtCore.Qt.AlignmentFlag.AlignRight + | QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + ) + layout.addWidget(lbl, 0, 2) + layout.addWidget(self.valueEdit, 0, 3) + + self.unitEdit = QtWidgets.QLineEdit(self) + lbl = QtWidgets.QLabel("Unit:") + lbl.setAlignment( + cast( + "QtCore.Qt.Alignment", + QtCore.Qt.AlignmentFlag.AlignRight + | QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + ) + layout.addWidget(lbl, 0, 4) + layout.addWidget(self.unitEdit, 0, 5) + + if typeInput: + self.typeSelect = QtWidgets.QComboBox(self) + names: list[str] = [] + for t, v in parameterTypes.items(): + names.append(str(v["name"])) + for n in sorted(names): + self.typeSelect.addItem(n) + self.typeSelect.setCurrentText( + str(parameterTypes[ParameterTypes.numeric]["name"]) + ) + lbl = QtWidgets.QLabel("Type:") + lbl.setAlignment( + cast( + "QtCore.Qt.Alignment", + QtCore.Qt.AlignmentFlag.AlignRight + | QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + ) + layout.addWidget(lbl, 1, 0) + layout.addWidget(self.typeSelect, 1, 1) + + self.valsArgsEdit = QtWidgets.QLineEdit(self) + lbl = QtWidgets.QLabel("Type opts.:") + lbl.setToolTip( + "Optional, for constraining parameter values." + "Allowed args and defaults:\n" + " - 'Numeric': min_value=-1e18, max_value=1e18\n" + " - 'Integer': min_value=-inf, max_value=inf\n" + " - 'String': min_length=0, max_length=1e9\n" + "See qcodes.utils.validators for details." + ) + lbl.setAlignment( + cast( + "QtCore.Qt.Alignment", + QtCore.Qt.AlignmentFlag.AlignRight + | QtCore.Qt.AlignmentFlag.AlignVCenter, + ) + ) + layout.addWidget(lbl, 1, 2) + layout.addWidget(self.valsArgsEdit, 1, 3) + + self.addButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/plus-square.svg"), " Add", parent=self + ) + + self.addButton.clicked.connect(self.requestNewParameter) + self.nameEdit.returnPressed.connect(self.addButton.click) + self.valueEdit.returnPressed.connect(self.addButton.click) + self.unitEdit.returnPressed.connect(self.addButton.click) + layout.addWidget(self.addButton, 0, 6, 1, 1) + self.addButton.setAutoDefault(True) + + self.clearButton = QtWidgets.QPushButton( + QtGui.QIcon(":/icons/delete.svg"), " Clear", parent=self + ) + + self.clearButton.setAutoDefault(True) + self.clearButton.clicked.connect(self.clear) + layout.addWidget(self.clearButton, 0, 7, 1, 1) + + self.setLayout(layout) + self.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Preferred, + QtWidgets.QSizePolicy.Policy.Fixed, + ) + self.invalidParamRequested.connect(self.setError) + + @QtCore.Slot() + def clear(self) -> None: + self.clearError() + self.nameEdit.setText("") + self.valueEdit.setText("") + self.unitEdit.setText("") + if self.typeInput: + self.typeSelect.setCurrentText( + parameterTypes[ParameterTypes.numeric]["name"] # type: ignore[arg-type] + ) + self.valsArgsEdit.setText("") + + @QtCore.Slot(bool) + def requestNewParameter(self, _: bool) -> None: + self.clearError() + + name = self.nameEdit.text().strip() + if len(name) == 0: + self.invalidParamRequested.emit("Name must not be empty.") + return + value = self.valueEdit.text() + unit = self.unitEdit.text() + + if hasattr(self, "typeSelect"): + ptype = paramTypeFromName(self.typeSelect.currentText()) + valsArgs = self.valsArgsEdit.text() + else: + ptype = ParameterTypes.any + valsArgs = "" + + self.newParamRequested.emit(name, value, unit, ptype, valsArgs) + + @QtCore.Slot(str) + def setError(self, message: str) -> None: + self.addButton.setStyleSheet(""" + QPushButton { background-color: red } + """) + self.addButton.setToolTip(message) + + def clearError(self) -> None: + self.addButton.setStyleSheet("") + self.addButton.setToolTip("") + + +class ModelParameterManager(ModelParameters): + #: Signal() -- + #: Emitted after a Broadcast changed the tree's structure: a parameter + #: was created or removed, or a ``parameter-update``/``parameter-call`` + #: added a row the model did not know. The Parameter Manager GUI + #: recomputes the Type claims that the tints and gutter bands show. + structureChanged = QtCore.Signal() + + #: Signal(str, object) -- + #: Emitted on a ``pm-lock-update`` Broadcast: the Follower's path relative + #: to the instrument, and its :class:`PMLockBluePrint` (``None`` when its + #: Lock was removed). No model item is touched for this action. + lockChanged = QtCore.Signal(str, object) + + #: Signal(str, object) -- + #: Emitted on a ``pm-type-update`` Broadcast: the Type's name (the part + #: after the instrument name), and its :class:`PMTypeBluePrint` (``None`` + #: when the Type was removed). No model item is touched for this action. + typeChanged = QtCore.Signal(str, object) + + def headerLabels(self) -> List[str]: + # the gutter column (plan task 5.2) and the Lock column (plan + # task 5.3) follow the name, unit and delegate columns + return super().headerLabels() + ["", "locked to"] + + def rowItems(self, item: QtGui.QStandardItem) -> List[QtGui.QStandardItem]: + return super().rowItems(item) + [ + QtGui.QStandardItem(), + QtGui.QStandardItem(), + ] + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + # set by newItem while a Broadcast is handled, so updateParameter + # knows the Broadcast added a row; rows added while the model loads + # or reloads never reach updateParameter's check + self._rows_added = False + self.newItem.connect(self._on_new_item) + + @QtCore.Slot(object) + def _on_new_item(self, item: QtGui.QStandardItem) -> None: + self._rows_added = True + + def parameter_units(self) -> Dict[str, str]: + """Every parameter row as ``{path: unit}``.""" + parameters: Dict[str, str] = {} + root = self.invisibleRootItem() + assert root is not None + self._collect_parameters(root, parameters) + return parameters + + def _collect_parameters( + self, parent: QtGui.QStandardItem, parameters: Dict[str, str] + ) -> None: + for row in range(parent.rowCount()): + item = cast(Optional[ItemBase], parent.child(row, 0)) + if item is None: + continue + if item.element is not None: + # a parameter row; a submodule row's element is None + unitItem = parent.child(row, 1) + parameters[item.name] = "" if unitItem is None else unitItem.text() + if item.hasChildren(): + self._collect_parameters(item, parameters) + + def updateParameter(self, bp: ParameterBroadcastBluePrint) -> None: + fullName = ".".join(bp.name.split(".")[1:]) + if bp.action == PM_LOCK_UPDATE: + # Locks and Types claim no model item of their own: the + # Parameter Manager GUI records the change in its PMState (D10) + self.lockChanged.emit(fullName, bp.value) + return + if bp.action == PM_TYPE_UPDATE: + self.typeChanged.emit(fullName, bp.value) + return + self._rows_added = False + super().updateParameter(bp) + # a creation, or a parameter-update or parameter-call for a row the + # model did not know, adds rows through the base branches; matching + # depends on which parameters exist, so the tints and gutter bands + # must be recomputed then (plan task 5.6), or the new row would + # stay untinted until the next recompute + if self._rows_added or bp.action == PARAMETER_DELETION: + self.structureChanged.emit() + + +class LockableParameterWidget(ParameterWidget): + """The value widget of a Parameter Manager row: a + :class:`~instrumentserver.gui.parameters.ParameterWidget` with the + row's lock button and delete button after the value. It keeps the lock + button so the Parameter Manager GUI can restyle it with the Lock + state.""" + + def __init__( + self, + parameter: Any, + lockButton: QtWidgets.QPushButton, + removeButton: QtWidgets.QPushButton, + parent: Optional[QtWidgets.QWidget] = None, + ) -> None: + super().__init__( + parameter=parameter, + parent=parent, + additionalWidgets=[lockButton, removeButton], + ) + self.lockButton = lockButton + + +class ParameterDeleteDelegate(ParameterDelegate): + #: Signal(str) + #: Emits the name of the parameter to be deleted when the user presses the delete button. + removeParameter = QtCore.Signal(str) + + #: Signal(str) + #: Emits the name of the parameter whose lock button the user pressed; + #: the Parameter Manager GUI toggles that parameter's Lock. + toggleLock = QtCore.Signal(str) + + def makeParameterWidget( + self, item: QtGui.QStandardItem, parent: QtWidgets.QWidget + ) -> ParameterWidget: + rw = self.makeRemoveWidget(item.name, parent) # type: ignore[attr-defined] + lw = self.make_lock_widget(item.name, parent) # type: ignore[attr-defined] + + return LockableParameterWidget( + item.element, # type: ignore[attr-defined] + lockButton=lw, + removeButton=rw, + parent=parent, + ) + + def make_lock_widget( + self, fullName: str, widget: QtWidgets.QWidget + ) -> QtWidgets.QPushButton: + """The per-row lock button. It stays hidden until the row carries a + Lock (a Lock-less row shows no button, as the mock), fills purple + while the Lock is locked, and only + :meth:`LocksController.apply_locks` changes its state.""" + w = make_lock_button(widget, locked=False) + w.setVisible(False) + + w.pressed.connect(lambda: self.toggleLock.emit(fullName)) + return w + + def makeRemoveWidget( + self, fullName: str, widget: QtWidgets.QWidget + ) -> QtWidgets.QPushButton: + w = QtWidgets.QPushButton(QtGui.QIcon(":/icons/delete.svg"), "", parent=widget) + w.setStyleSheet(""" + QPushButton { background-color: salmon } + """) + w.setToolTip("Delete this parameter") + keepSmallHorizontally(w) + + w.pressed.connect(lambda: self.removeParameter.emit(fullName)) + return w + + +# TODO: Make sure that the refresh button refreshes the profiles as well as the model +class ParameterManagerTreeView(ParametersTreeView): + #: Signal(str) + #: Emitted when the user picks "Lock to…" in the context menu; the + #: Parameter Manager GUI arms the target picker for that parameter. + lockToRequested = QtCore.Signal(str) + + #: Signal(str) + #: Emitted when the user picks "Unlock" in the context menu. + unlockRequested = QtCore.Signal(str) + + delegateClass = ParameterDeleteDelegate + delegate: ParameterDeleteDelegate + + def __init__( + self, + model: QtCore.QAbstractItemModel, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(model, *args, **kwargs) + + # the lock actions act on the row the context menu was opened for + # (self.lastSelectedItem, set by the base onContextMenuRequested + # before the menu opens); the Parameter Manager GUI enables and + # disables them in its aboutToShow slot + self.lockToAction = QtWidgets.QAction("Lock to…") + self.lockToAction.triggered.connect(self._on_lock_to_action_trigger) + self.unlockAction = QtWidgets.QAction("Unlock") + self.unlockAction.triggered.connect(self._on_unlock_action_trigger) + self.contextMenu.addSeparator() + self.contextMenu.addAction(self.lockToAction) + self.contextMenu.addAction(self.unlockAction) + + def setupColumns(self) -> None: + # the view is built over a ModelParameterManager, whose rows carry + # the gutter and Lock columns + self.gutterDelegate = GutterDelegate(self) + self.setItemDelegateForColumn(GUTTER_COLUMN, self.gutterDelegate) + header = self.header() + assert header is not None + # the gutter moves to visual position 0 with a fixed width; the + # tree branches stay on the name column + header.moveSection(GUTTER_COLUMN, 0) + if header.minimumSectionSize() > GUTTER_WIDTH: + header.setMinimumSectionSize(GUTTER_WIDTH) + header.setSectionResizeMode( + GUTTER_COLUMN, QtWidgets.QHeaderView.ResizeMode.Fixed + ) + header.resizeSection(GUTTER_COLUMN, GUTTER_WIDTH) + # the Lock column moves between the unit and the delegate column, + # with a resizable default width + header.moveSection(header.visualIndex(LOCK_COLUMN), header.visualIndex(2)) + header.setSectionResizeMode( + LOCK_COLUMN, QtWidgets.QHeaderView.ResizeMode.Interactive + ) + header.resizeSection(LOCK_COLUMN, LOCK_COLUMN_WIDTH) + self.setTreePosition(0) + + @QtCore.Slot() + def _on_lock_to_action_trigger(self) -> None: + """The context menu's "Lock to…": arm the target picker for the + row's parameter; a submodule row has no Lock to arm.""" + item = self.lastSelectedItem + if item is not None and item.element is not None: + self.lockToRequested.emit(item.name) + + @QtCore.Slot() + def _on_unlock_action_trigger(self) -> None: + """The context menu's "Unlock": unlock the row's Lock; a submodule + row has no Lock to unlock.""" + item = self.lastSelectedItem + if item is not None and item.element is not None: + self.unlockRequested.emit(item.name) + + +class ProfilesManager(QtWidgets.QComboBox): + #: Signal() + #: Emitted when the selected index changed. + indexChanged = QtCore.Signal() + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + + self.setEditable(False) + self.params = self.parent().instrument # type: ignore[union-attr] + self.refreshing = False + + loadingProfile = None + for profile in self.params.list_profiles(): + self.addItem(self.params.cleanProfileName(profile)) + if loadingProfile is None: + loadingProfile = profile + + self.currentIndexChanged.connect(self.onCurrentIndexChanged) + + def refresh(self) -> None: + self.refreshing = True + currentlySelected = self.currentText() + self.clear() + for profile in self.params.list_profiles(): + self.addItem(self.params.cleanProfileName(profile)) + if self.params.cleanProfileName(profile) == currentlySelected: + self.setCurrentIndex(self.count() - 1) + self.refreshing = False + + @QtCore.Slot(int) + def onCurrentIndexChanged(self, index: int) -> None: + if not self.refreshing: + self.indexChanged.emit() + + +class LocksController(QtCore.QObject): + """The Lock behaviour of a :class:`ParameterManagerGui` (plan tasks + 5.3, 5.4 and 5.6): the tree's Lock column, lock buttons and read-only + values, the lock and unlock actions and shortcuts, the arm strip's + target pick, and the Locks panel. It works on the GUI's widgets and + state and wires them in :meth:`connectSignals`. + + ``armed_follower`` is the Follower a pick is armed for, and + ``armed_type_lock`` the (Type, entry) pair a Type Lock re-target from + the Types tab (plan task 5.5) is armed for; both are ``None`` while + nothing is armed. + """ + + def __init__(self, gui: "ParameterManagerGui") -> None: + super().__init__(gui) + self.gui = gui + self.armed_follower: Optional[str] = None + self.armed_type_lock: Optional[Tuple[str, str]] = None + + def connectSignals(self) -> None: + gui = self.gui + gui.view.delegate.toggleLock.connect(self._toggle_lock) + gui.model.lockChanged.connect(self._on_lock_changed) + gui.model.structureChanged.connect(self.apply_locks) + gui.model.itemNewValue.connect(self._on_item_new_value) + # the filter (and the trash toggle) hides rows; when they come + # back, restoreCollapsedDict has re-opened their persistent + # editors, so createEditor has built fresh ParameterWidgets whose + # lock button is hidden and whose input is editable — re-apply the + # Lock state to them + gui.proxyModel.filterFinished.connect(self.apply_locks) + gui.view.lockToRequested.connect(self.arm_lock) + gui.view.unlockRequested.connect(self._unlock) + gui.view.contextMenu.aboutToShow.connect(self._update_lock_actions) + gui.view.clicked.connect(self._on_view_clicked) + gui.viewEscShortcut.activated.connect(self.cancel_arm) + gui.armStrip.targetPicked.connect(self.pick_lock_target) + gui.armStrip.cancelled.connect(self.cancel_arm) + # the Locks panel (plan task 5.4): the tree's current row drives + # the panel's selected label + assert gui.locksAction is not None # created by the GUI's toolbar + gui.locksAction.toggled.connect(self._on_locks_action_toggled) + gui.locksPanel.toggleLockRequested.connect(self._on_panel_toggle_lock) + gui.locksPanel.removeLockRequested.connect(self._on_panel_remove_lock) + gui.locksPanel.lockAllRequested.connect(self._on_panel_lock_all) + gui.locksPanel.removeRuleRequested.connect(self._on_panel_remove_rule) + gui.locksPanel.lockSelectionRequested.connect(self._lock_selection_from_panel) + treeSelection = gui.view.selectionModel() + assert treeSelection is not None # set together with the model + treeSelection.currentChanged.connect(self._on_tree_current_changed) + # the Types tab's Type Lock re-target arms the same strip + gui.typesPane.retargetTypeLockRequested.connect(self.arm_type_lock) + gui.shortcutManager.register("toggle_locks", gui.locksAction.toggle, gui) + # the Lock shortcuts (plan task 5.6); the tree's two lock actions + # carry their key in their tooltips + gui.shortcutManager.register("lock_to", self._lock_current_item, gui) + gui.shortcutManager.register("unlock_item", self._unlock_current_item, gui) + gui.shortcutManager.register_tooltip("lock_to", gui.view.lockToAction) + gui.shortcutManager.register_tooltip("unlock_item", gui.view.unlockAction) + + @QtCore.Slot(str, object) + def _on_lock_changed(self, path: str, lock: Optional[PMLockBluePrint]) -> None: + """Record the change a ``pm-lock-update`` Broadcast reports about + the Follower at ``path``, then recompute the Lock column and the + row widgets, and repaint the values the change alters: the + Follower's own and every row whose chain of locked Locks reaches + it, since locking and unlocking change what ``get`` answers — in + the tree and, while it is shown, in the Locks panel.""" + self.gui.state.apply_lock(path, lock) + self.apply_locks() + refreshed = [ + path, + *followers_reaching(path, self.gui.state.locks, self.gui.instrument.name), + ] + for follower in refreshed: + self._refresh_row_widget(follower) + if not self.gui.locksPanel.isHidden(): + self.gui.locksPanel.refresh_values(refreshed) + + @QtCore.Slot(object, object) + def _on_item_new_value(self, path: object, value: object) -> None: + """Repaint every Follower whose locked Lock chain reaches the + parameter a ``parameter-update`` or ``parameter-call`` Broadcast + names (D3: a locked Follower answers ``get`` with the Target's + value, and the Parameter Manager emits nothing for values). The + Broadcast's own row is refreshed by the base wiring to + ``view.onItemNewValue``; this slot handles the rows behind it — + and, while the Locks panel is shown, the same paths there. + + Every row is painted with the Broadcast's ``value``, never a fresh + ``get``: the Server broadcasts a ``parameter-call`` for every + ``get``, so a ``get`` here would bring this slot back for the same + path, forever.""" + followers = followers_reaching( + str(path), self.gui.state.locks, self.gui.instrument.name + ) + for follower in followers: + if follower in self.gui.view.delegate.parameters: + self.gui.view.onItemNewValue(follower, value) + if not self.gui.locksPanel.isHidden(): + self.gui.locksPanel.show_value([str(path), *followers], value) + + def _refresh_row_widget(self, path: str) -> None: + """Re-read the parameter behind the row at ``path`` through the + Proxy, which pulls the Target's value for a locked Follower, and + show it on the row's widget.""" + widget = self.gui.view.delegate.parameters.get(path) + if widget is None: + return + try: + widget.setWidgetFromParameter() + except RuntimeError: + logger.debug( + f"Could not refresh the value of {path}. " + "Object is not being shown right now." + ) + + @QtCore.Slot() + def apply_locks(self) -> None: + """Recompute every parameter row's Lock state from the client-side + state (plan task 5.3): the Lock column text, the lock button's + visibility, tooltip and purple fill, and whether the value renders + read-only. + + Runs after the state was refreshed from the Parameter Manager (on a + model reload), on every ``pm-lock-update`` Broadcast, and after a + parameter was created or removed by a Broadcast. Recomputing all + rows on every change is fine — the tree is small — and keeps one + clear path. The Locks panel is rebuilt with the same state at the + end, but only while it is shown.""" + self._apply_locks_to_rows(self.gui.model.invisibleRootItem()) + self.refresh_locks_panel() + + def _apply_locks_to_rows(self, parent: QtGui.QStandardItem) -> None: + """Walk the source model (never the proxy) and set each row's Lock + column text, lock button state and read-only flag.""" + for row in range(parent.rowCount()): + item = cast(Optional[ItemBase], parent.child(row, 0)) + if item is None: + continue + lockItem = parent.child(row, LOCK_COLUMN) + # ModelParameterManager.rowItems gives every row this item + assert lockItem is not None + if item.element is None: + # a submodule row carries no Lock state of its own + lockItem.setText("") + else: + lockItem.setText( + lock_column_text( + item.name, self.gui.state.locks, self.gui.instrument.name + ) + ) + widget = self.gui.view.delegate.parameters.get(item.name) + if widget is not None: + self._update_row_lock_widget(item.name, widget) + if item.hasChildren(): + self._apply_locks_to_rows(item) + + def _update_row_lock_widget(self, path: str, widget: "ParameterWidget") -> None: + """Set one row's lock button and read-only state from the Lock the + state holds for ``path``. A row without a Lock shows no button and + renders its value editable.""" + button = ( + widget.lockButton if isinstance(widget, LockableParameterWidget) else None + ) + lock = self.gui.state.locks.get(path) + if lock is None: + if button is not None: + button.setVisible(False) + widget.set_read_only(False) + return + target = relative_path(lock.target, self.gui.instrument.name) + tooltip = lock_button_tooltip(lock.locked, target) + if button is not None: + button.setToolTip(tooltip) + button.setProperty("locked", lock.locked) + # re-polish so the locked property restyles the button + style = button.style() + if style is not None: + style.unpolish(button) + style.polish(button) + button.setVisible(True) + widget.set_read_only(lock.locked) + + @QtCore.Slot(str) + def _toggle_lock(self, path: str) -> None: + """Toggle the Lock of the parameter at ``path`` (the row's lock + button). A refused toggle — relocking would close a cycle (D7) — + shows the Server's error text on the row's alert widget.""" + widget = self.gui.view.delegate.parameters.get(path) + try: + self.gui.instrument.toggle_lock(path) + except Exception as e: + if widget is not None: + widget.alertWidget.setAlert(str(e)) + + @QtCore.Slot(str) + def _unlock(self, path: str) -> None: + """Unlock the Lock of the parameter at ``path`` (the context + menu's "Unlock"). A refused unlock shows the Server's error text + on the row's alert widget.""" + widget = self.gui.view.delegate.parameters.get(path) + try: + self.gui.instrument.unlock(path) + except Exception as e: + if widget is not None: + widget.alertWidget.setAlert(str(e)) + + def _lock_current_item(self) -> None: + """The lock_to shortcut (plan task 5.6): arm the target picker for + the tree's current parameter row. A submodule row or no selection + does nothing.""" + item = self.gui._getCurrentItem() + if item is not None and item.element is not None: + self.arm_lock(item.name) + + def _unlock_current_item(self) -> None: + """The unlock_item shortcut (plan task 5.6): unlock the Lock of + the tree's current parameter row while it is locked; a row + without a locked Lock does nothing. A refused unlock shows the + Server's error text on the row's alert widget, like the context + menu's Unlock.""" + item = self.gui._getCurrentItem() + if item is None or item.element is None: + return + lock = self.gui.state.locks.get(item.name) + if lock is None or not lock.locked: + return + self._unlock(item.name) + + @QtCore.Slot() + def _update_lock_actions(self) -> None: + """Enable the context menu's lock actions for the row the menu was + opened on: "Lock to…" for every parameter row, "Unlock" only for a + parameter whose Lock in the state is locked.""" + item = self.gui.view.lastSelectedItem + is_parameter = item is not None and item.element is not None + self.gui.view.lockToAction.setEnabled(is_parameter) + self.gui.view.unlockAction.setEnabled( + is_parameter + and item.name in self.gui.state.locks + and self.gui.state.locks[item.name].locked + ) + + def arm_lock(self, follower: str) -> None: + """Arm the target picker for the Follower at ``follower``: the + candidates are every other parameter row of the source model, + ranked like the mock's completer (same relative path inside its + Instance first), and the strip shows under the toolbar. Arming + while already armed re-arms for the new Follower.""" + parameters = self.gui.model.parameter_units() + claims = compute_claims(self.gui.state.types, parameters) + self.armed_follower = follower + self.armed_type_lock = None + self.gui.armStrip.arm(follower, rank_lock_targets(follower, parameters, claims)) + + def arm_type_lock(self, type_name: str, path: str) -> None: + """Arm the target picker for the Type Lock of the entry ``path`` + of the Type ``type_name`` (plan task 5.5): the strip shows on the + Parameters tab with the entry named in its label, and the + candidates are every parameter row of the source model ranked + with the entry path as the relative path (the same leaf on any + Instance first). Arming while already armed re-arms for the new + entry.""" + self.armed_type_lock = (type_name, path) + self.armed_follower = None + self.gui.tabs.setCurrentIndex(0) + parameters = self.gui.model.parameter_units() + claims = compute_claims(self.gui.state.types, parameters) + self.gui.armStrip.arm( + f"type {type_name} \u00b7 {path}", + rank_lock_targets("", parameters, claims, arm_rel=path), + ) + + def pick_lock_target(self, target: str) -> None: + """Pick ``target`` as the Target of the armed pick — a Follower's + Lock (plan task 5.3) or, while a Type Lock re-target is armed, the + Type Lock declaration with ``target`` as its Target (plan task + 5.5). A refused Lock — a cycle (D7), a self-lock — shows the + Server's error text on the strip and stays armed so another + target can be picked; a successful Lock disarms the strip, and a + Type Lock declaration names the Instance parameters it skipped + (D17) on the entries pane's note.""" + if self.armed_type_lock is not None: + type_name, path = self.armed_type_lock + try: + skipped = self.gui.instrument.lock_type_parameter( + type_name, path, target=target + ) + except Exception as exc: + self.gui.armStrip.show_error(str(exc)) + else: + if skipped: + self.gui.typesPane.show_entries_note( + f"skipped: {', '.join(skipped)}" + ) + else: + # a clean declaration leaves no stale error or + # skipped note behind (plan task 5.6) + self.gui.typesPane.reset_entries_note() + self.cancel_arm() + return + if self.armed_follower is None: + return + try: + self.gui.instrument.lock(self.armed_follower, target) + except Exception as exc: + self.gui.armStrip.show_error(str(exc)) + else: + self.cancel_arm() + + def cancel_arm(self) -> None: + """Disarm the target picker without picking anything (either kind + of pick: a Follower's Lock or a Type Lock's re-target).""" + self.armed_follower = None + self.armed_type_lock = None + self.gui.armStrip.disarm() + + @QtCore.Slot(QtCore.QModelIndex) + def _on_view_clicked(self, index: QtCore.QModelIndex) -> None: + """A row click while the pick is armed chooses that row's parameter + as the Target (the mock's rowClick); a submodule click does + nothing.""" + if self.armed_follower is None and self.armed_type_lock is None: + return + source_index = self.gui.proxyModel.mapToSource(index) + source_index = source_index.sibling(source_index.row(), 0) + item = self.gui.model.itemFromIndex(source_index) + if item is not None and item.element is not None: + self.pick_lock_target(item.name) + + @QtCore.Slot(bool) + def _on_locks_action_toggled(self, checked: bool) -> None: + """Show or hide the Locks panel with the toolbar action, and + rebuild its rows when it becomes visible (a hidden panel costs + nothing).""" + self.gui.locksPanel.setVisible(checked) + if checked: + self.refresh_locks_panel() + + @QtCore.Slot() + def refresh_locks_panel(self) -> None: + """Rebuild the Locks panel's rows from the client-side state (plan + task 5.4): the rows from ``PMState.locks`` and ``PMState.types``, + each row's parameter resolved through the instrument. + + Runs at the end of :meth:`apply_locks` and of + :meth:`TypesController._on_type_changed` — the Type Lock rows + depend on the Types — and when the toolbar action shows the panel, + but only while the panel is shown, so a hidden panel costs + nothing.""" + if self.gui.locksPanel.isHidden(): + return + rows = build_lock_rows( + self.gui.state.locks, self.gui.state.types, self.gui.instrument.name + ) + elements: Dict[str, Any] = {} + for path in lock_row_paths(rows): + try: + elements[path] = nestedAttributeFromString(self.gui.instrument, path) + except (AttributeError, RuntimeError) as exc: + logger.debug( + f"could not resolve the parameter of the Locks panel " + f"row {path}: {exc}" + ) + self.gui.locksPanel.rebuild( + rows, elements, self.gui.state.types, self.gui.state.locks + ) + + @QtCore.Slot(str) + def _on_panel_toggle_lock(self, path: str) -> None: + """The Locks panel's lock/relock toggle: toggle the Lock of the + parameter at ``path``. A refused toggle shows the Server's error + text on the panel's note label.""" + try: + self.gui.instrument.toggle_lock(path) + except Exception as exc: + self.gui.locksPanel.show_error(str(exc)) + else: + self.gui.locksPanel.reset_note() + + @QtCore.Slot(str) + def _on_panel_remove_lock(self, path: str) -> None: + """The Locks panel's remove button: remove the Lock of the + parameter at ``path``. A refused removal shows the Server's error + text on the panel's note label.""" + try: + self.gui.instrument.remove_lock(path) + except Exception as exc: + self.gui.locksPanel.show_error(str(exc)) + else: + self.gui.locksPanel.reset_note() + + @QtCore.Slot(str, str, str) + def _on_panel_lock_all(self, type_name: str, entry: str, target: str) -> None: + """The Type Lock row's "lock all" button: declare the Type Lock + again with the entry's stored Target — called with ``target=None`` + the Server would re-point the rule to the Globals default (D17). + Instance parameters the declaration skips, because they carry a + Lock on another Target (D17), are named on the note label.""" + try: + skipped = self.gui.instrument.lock_type_parameter( + type_name, entry, target=target + ) + except Exception as exc: + self.gui.locksPanel.show_error(str(exc)) + else: + if skipped: + self.gui.locksPanel.show_note(f"skipped: {', '.join(skipped)}") + else: + self.gui.locksPanel.reset_note() + + @QtCore.Slot(str, str) + def _on_panel_remove_rule(self, type_name: str, entry: str) -> None: + """The Type Lock row's "remove rule" button: remove only the rule + (D17); the Locks it created stay until they are removed one by + one. A refused removal shows the Server's error text on the + panel's note label.""" + try: + self.gui.instrument.unlock_type_parameter(type_name, entry) + except Exception as exc: + self.gui.locksPanel.show_error(str(exc)) + else: + self.gui.locksPanel.reset_note() + + @QtCore.Slot() + def _lock_selection_from_panel(self) -> None: + """The panel's "Lock selection to…": arm the target picker for the + tree's current parameter row. With no parameter row current, the + note label says so and nothing is armed; a successful arm clears a + stale error from the note (plan task 5.6).""" + item = self.gui._getCurrentItem() + if item is None or item.element is None: + self.gui.locksPanel.show_error("Select a parameter in the tree first.") + return + self.gui.locksPanel.reset_note() + self.arm_lock(item.name) + + @QtCore.Slot(QtCore.QModelIndex, QtCore.QModelIndex) + def _on_tree_current_changed( + self, current: QtCore.QModelIndex, previous: QtCore.QModelIndex + ) -> None: + """Keep the panel's selected label on the tree's current row: a + parameter row shows its path, a submodule row or no selection shows + "no parameter selected".""" + item = self.gui._getCurrentItem() + if item is not None and item.element is not None: + self.gui.locksPanel.selectedLabel.setText(item.name) + else: + self.gui.locksPanel.selectedLabel.setText("no parameter selected") + + +class TypesController(QtCore.QObject): + """The Type behaviour of a :class:`ParameterManagerGui` (plan tasks + 5.2 and 5.5): the tree's tints and gutter bands, and the Types tab's + actions. It works on the GUI's widgets and state and wires them in + :meth:`connectSignals`.""" + + def __init__(self, gui: "ParameterManagerGui") -> None: + super().__init__(gui) + self.gui = gui + + def connectSignals(self) -> None: + gui = self.gui + gui.model.typeChanged.connect(self._on_type_changed) + gui.model.structureChanged.connect(self.apply_tints) + # the Types pane (plan task 5.5): a selection change re-renders + # the two panes it drives + gui.typesPane.typeSelected.connect(self._on_pane_type_selected) + gui.typesPane.addTypeRequested.connect(self._on_pane_add_type) + gui.typesPane.addEntryRequested.connect(self._on_pane_add_entry) + gui.typesPane.removeEntryRequested.connect(self._on_pane_remove_entry) + gui.typesPane.setDefaultRequested.connect(self._on_pane_set_default) + gui.typesPane.toggleTypeLockRequested.connect(self._on_pane_toggle_type_lock) + gui.typesPane.addNestedRequested.connect(self._on_pane_add_nested) + gui.typesPane.removeNestedRequested.connect(self._on_pane_remove_nested) + gui.typesPane.addInstanceRequested.connect(self._on_pane_add_instance) + gui.typesPane.showInstanceRequested.connect(self._on_pane_show_instance) + + @QtCore.Slot(str, object) + def _on_type_changed( + self, name: str, type_blueprint: Optional[PMTypeBluePrint] + ) -> None: + """Record the change a ``pm-type-update`` Broadcast reports about + the Type ``name`` in the state, then recompute the tints and gutter + bands it may change, and rebuild the Locks panel (its Type Lock + rows depend on the Types).""" + self.gui.state.apply_type(name, type_blueprint) + self.apply_tints() + self.gui.locksController.refresh_locks_panel() + + @QtCore.Slot() + def apply_tints(self) -> None: + """Recompute every row's Type claims and repaint the tints and + gutter bands (plan task 5.2). + + Runs after the state was refreshed from the Parameter Manager (on a + model reload), on every ``pm-type-update`` Broadcast, and after a + parameter was created or removed by a Broadcast, since matching + depends on which parameters exist. The Types pane rebuilds from + the same state at the end (plan task 5.5). + """ + self.gui.typePalette.sync(self.gui.state.types) + claims = compute_claims(self.gui.state.types, self.gui.model.parameter_units()) + self._apply_tints_to_rows(self.gui.model.invisibleRootItem(), claims) + self.refresh_types_pane() + + def _apply_tints_to_rows( + self, parent: QtGui.QStandardItem, claims: Dict[str, Claim] + ) -> None: + """Tint every row of ``parent`` that has a Claim with the Claiming + Type's colour on all columns and store its Type stack on the gutter + item; clear the background of the rows without one.""" + for row in range(parent.rowCount()): + rowItems = [parent.child(row, col) for col in range(LOCK_COLUMN + 1)] + item = cast(Optional[ItemBase], rowItems[0]) + if item is None: + continue + gutterItem = rowItems[GUTTER_COLUMN] + # ModelParameterManager.rowItems gives every row this item + assert gutterItem is not None + claim = claims.get(item.name) + colours = ( + self.gui.typePalette.colours(claim.type) if claim is not None else None + ) + if claim is not None and colours is not None: + # claimed rows carry the Claiming Type's tint, alternating + # with tintAlt over the sibling rows so the striping survives + background = colours["tintAlt"] if item.row() % 2 else colours["tint"] + for rowItem in rowItems: + if rowItem is not None: + rowItem.setData( + background, QtCore.Qt.ItemDataRole.BackgroundRole + ) + gutterItem.setData(claim.stack[:3], GUTTER_ROLE) + else: + # a lost claim reverts the row to the default look + for rowItem in rowItems: + if rowItem is not None: + rowItem.setData(None, QtCore.Qt.ItemDataRole.BackgroundRole) + gutterItem.setData([], GUTTER_ROLE) + if item.hasChildren(): + self._apply_tints_to_rows(item, claims) + + @QtCore.Slot() + def refresh_types_pane(self) -> None: + """Rebuild the Types pane's three panes from the client-side state + (plan task 5.5): the Types, the model's parameter rows and the + tint palette. + + Runs at the end of :meth:`apply_tints` — so a refresh, a profile + load, a ``pm-type-update`` Broadcast and a structural Broadcast + all refresh it — and after every pane action's Server call + returns (the Broadcast arrives on top of that; a double rebuild + is fine). The pane keeps the selected Type across rebuilds and + drops the selection when the Type is gone.""" + self.gui.typesPane.rebuild( + self.gui.state.types, self.gui.model.parameter_units(), self.gui.typePalette + ) + + @QtCore.Slot(str) + def _on_pane_type_selected(self, name: str) -> None: + """The Types pane's selected Type changed: re-render the entries + and Instances panes for it.""" + self.gui.typesPane.refresh_selected_panes( + self.gui.state.types, self.gui.model.parameter_units(), self.gui.typePalette + ) + + @QtCore.Slot(str) + def _on_pane_add_type(self, name: str) -> None: + """The New type strip: create the Type. A refused creation shows + the Server's error text on the strip's note; on success the new + Type is selected once the pane rebuilds (the ``pm-type-update`` + Broadcast brings it into the state).""" + try: + self.gui.instrument.add_type(name) + except Exception as exc: + self.gui.typesPane.show_type_error(str(exc)) + else: + self.gui.typesPane.reset_type_note() + self.gui.typesPane.newTypeEdit.clear() + self.gui.typesPane.select_type(name) + self.refresh_types_pane() + + @QtCore.Slot(str, str, str, str) + def _on_pane_add_entry( + self, type_name: str, path: str, default_text: str, unit: str + ) -> None: + """The "Add to type" strip: add the entry with its parsed default + (``None`` when the text is empty) and unit (D11, D13). A refused + edit shows the Server's error text on the entries pane's note.""" + try: + self.gui.instrument.add_type_parameter( + type_name, path, default=parse_default_text(default_text), unit=unit + ) + except Exception as exc: + self.gui.typesPane.show_entries_error(str(exc)) + else: + self.gui.typesPane.reset_entries_note() + self.gui.typesPane.entryNameEdit.clear() + self.gui.typesPane.entryDefaultEdit.clear() + self.gui.typesPane.entryUnitEdit.clear() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_remove_entry(self, type_name: str, path: str) -> None: + """An own entry's Remove button: remove the entry from the Type + only (D13) — the Instances keep the parameter. A refused removal + shows the Server's error text on the entries pane's note.""" + try: + self.gui.instrument.remove_type_parameter(type_name, path) + except Exception as exc: + self.gui.typesPane.show_entries_error(str(exc)) + else: + self.gui.typesPane.reset_entries_note() + self.refresh_types_pane() + + @QtCore.Slot(str, str, str) + def _on_pane_set_default(self, type_name: str, path: str, text: str) -> None: + """An own entry's committed default editor (Return or the set + button): set the entry's default to the parsed text (D13). A + refused set shows the Server's error text on the entries pane's + note.""" + try: + self.gui.instrument.set_type_parameter_default( + type_name, path, parse_default_text(text) + ) + except Exception as exc: + self.gui.typesPane.show_entries_error(str(exc)) + else: + self.gui.typesPane.reset_entries_note() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_toggle_type_lock(self, type_name: str, path: str) -> None: + """An entry's Type Lock toggle: declare the Type Lock on the + default Globals Target while the entry has no Target, remove only + the rule while it has one (D17). A refused toggle shows the + Server's error text on the entries pane's note; the Instance + parameters a declaration skips (D17) are named on it.""" + blueprint = self.gui.state.types.get(type_name) + target = None + if blueprint is not None: + target = blueprint.parameters.get(path, {}).get("target") + try: + if target is None: + skipped = self.gui.instrument.lock_type_parameter(type_name, path) + else: + self.gui.instrument.unlock_type_parameter(type_name, path) + skipped = [] + except Exception as exc: + self.gui.typesPane.show_entries_error(str(exc)) + else: + if skipped: + self.gui.typesPane.show_entries_note(f"skipped: {', '.join(skipped)}") + else: + self.gui.typesPane.reset_entries_note() + self.refresh_types_pane() + + @QtCore.Slot(str, str, str) + def _on_pane_add_nested(self, type_name: str, submodule: str, nested: str) -> None: + """The "Nested type" strip: require the Nested Type ``nested`` at + the submodule (D11, D13). A refused edit shows the Server's error + text on the entries pane's note.""" + try: + self.gui.instrument.add_nested_type(type_name, submodule, nested) + except Exception as exc: + self.gui.typesPane.show_entries_error(str(exc)) + else: + self.gui.typesPane.reset_entries_note() + self.gui.typesPane.nestedAtEdit.clear() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_remove_nested(self, type_name: str, submodule: str) -> None: + """An own Nested Type's Remove button: remove the requirement + (D13) — the Instances keep the parameters. A refused removal + shows the Server's error text on the entries pane's note.""" + try: + self.gui.instrument.remove_nested_type(type_name, submodule) + except Exception as exc: + self.gui.typesPane.show_entries_error(str(exc)) + else: + self.gui.typesPane.reset_entries_note() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_add_instance(self, type_name: str, name: str) -> None: + """The New instance strip: create the Instance ``name`` of the + Type (D14). A refused creation shows the Server's error text on + the instances pane's note.""" + try: + self.gui.instrument.add_instance(type_name, name) + except Exception as exc: + self.gui.typesPane.show_instances_error(str(exc)) + else: + self.gui.typesPane.reset_instances_note() + self.gui.typesPane.newInstanceEdit.clear() + self.refresh_types_pane() + + @QtCore.Slot(str, str) + def _on_pane_show_instance(self, type_name: str, instance: str) -> None: + """An instance row's Show button: switch to the Parameters tab, + clear the filter, expand the tree and select the Instance's first + parameter row — the first effective entry under it, the submodule + row as the fallback — scrolled into view.""" + self.gui.tabs.setCurrentIndex(0) + self.gui.lineEdit.clear() + self.gui.view.expandAll() + blueprint = self.gui.state.types.get(type_name) + candidates = [instance] + if blueprint is not None and blueprint.effective: + first = sorted(blueprint.effective, key=lambda entry: entry.split("."))[0] + candidates.insert(0, f"{instance}.{first}") + for path in candidates: + matches = self.gui.model.findItems( + path, + cast( + "QtCore.Qt.MatchFlags", + QtCore.Qt.MatchFlag.MatchExactly + | QtCore.Qt.MatchFlag.MatchRecursive, + ), + 0, + ) + if not matches: + continue + proxy_index = self.gui.proxyModel.mapFromSource( + self.gui.model.indexFromItem(matches[0]) + ) + if proxy_index.isValid(): + self.gui.view.setCurrentIndex(proxy_index) + self.gui.view.scrollTo(proxy_index) + break + + +class ParameterManagerGui(InstrumentParameters): + #: Signal(str) -- + #: emitted when there's an error during parameter creation. + parameterCreationError = QtCore.Signal(str) + + #: Signal() -- + #: emitted when a parameter was created successfully + parameterCreated = QtCore.Signal() + + def __init__( + self, + instrument: Union[ProxyInstrument, ParameterManager], + parent: Optional[QtWidgets.QWidget] = None, + **kwargs: Any, + ) -> None: + super().__init__( + instrument, + parent=None, + viewType=ParameterManagerTreeView, + callSignals=False, + modelType=ModelParameterManager, + **kwargs, + ) + # The client-side cache of the Parameter Manager's Types and Locks. + # Created before connectSignals, which wires the model's Broadcast + # routing into it. + self.state = PMState() + # The tint palette: maps each Type to its slot in TINT_PALETTE (or + # TINT_PALETTE_DARK in a dark theme); the view's gutter delegate + # reads the colours from it. + self.typePalette = TypePalette() + # The theme the tints were last applied for; changeEvent re-tints + # when the application switches between light and dark. + self._darkTheme = is_dark_theme() + self.view.gutterDelegate.typePalette = self.typePalette + self.profileManager = ProfilesManager(parent=self) + self.addParam = AddParameterWidget(parent=self) + layout = self.layout() + assert isinstance(layout, QtWidgets.QVBoxLayout) + layout.insertWidget(0, self.profileManager) + layout.addWidget(self.addParam) + # The Locks panel (plan task 5.4) sits right of the tree in a + # splitter: the view keeps its identity, so every existing layout + # consumer and test keeps working. The panel starts hidden and + # costs nothing until the toolbar action shows it. + self.locksPanel = LocksPanel(self.instrument.name, parent=self) + view_index = layout.indexOf(self.view) + layout.removeWidget(self.view) + self.locksSplitter = QtWidgets.QSplitter(QtCore.Qt.Orientation.Horizontal, self) + self.locksSplitter.addWidget(self.view) + self.locksSplitter.addWidget(self.locksPanel) + self.locksSplitter.setStretchFactor(0, 3) + self.locksSplitter.setStretchFactor(1, 2) + # Stretch 1 so the tree takes all spare height and the Add strip + # stays pinned to the bottom. + layout.insertWidget(view_index, self.locksSplitter, 1) + self.locksPanel.setVisible(False) + # The existing content becomes tab 0 of the tab widget; tab 1 + # holds the Types pane (plan task 5.5). + self.parametersTab = QtWidgets.QWidget(self) + self.parametersTab.setLayout(self.layout()) + self.typesTab = QtWidgets.QWidget(self) + typesTabLayout = QtWidgets.QVBoxLayout(self.typesTab) + typesTabLayout.setContentsMargins(0, 0, 0, 0) + self.typesPane = TypesPane(self.instrument.name, parent=self.typesTab) + typesTabLayout.addWidget(self.typesPane) + self.tabs = QtWidgets.QTabWidget(self) + self.tabs.addTab(self.parametersTab, "Parameters") + self.tabs.addTab(self.typesTab, "Types") + outerLayout = QtWidgets.QVBoxLayout(self) + outerLayout.setContentsMargins(0, 0, 0, 0) + outerLayout.addWidget(self.tabs) + # The arm strip sits right under the toolbar and stays hidden until + # a Lock's Target is being picked (plan task 5.3); the Locks + # controller keeps what the pick is armed for. + self.armStrip = LockArmStrip(self.parametersTab) + parametersLayout = self.parametersTab.layout() + assert isinstance(parametersLayout, QtWidgets.QVBoxLayout) + toolbar_index = parametersLayout.indexOf(self.toolbar) + parametersLayout.insertWidget(toolbar_index + 1, self.armStrip) + self.armStrip.setVisible(False) + # Escape over the tree cancels the pick too (harmless when the + # strip is not armed); the Locks controller connects it + self.viewEscShortcut = QtWidgets.QShortcut( + QtGui.QKeySequence("Escape"), self.view + ) + self.viewEscShortcut.setContext(QtCore.Qt.ShortcutContext.WidgetShortcut) + # The confirmation dialog for removing a Lock Target (plan task + # 5.6), kept on the GUI so tests can drive it; ``None`` while no + # removal that needs one is in flight. + self.removalDialog: Optional[QtWidgets.QMessageBox] = None + # The Types tab and tints, and the Lock column, arm strip and Locks + # panel, each run through a controller that wires its own widgets + self.typesController = TypesController(self) + self.locksController = LocksController(self) + self.connectSignals() + self.loadProfile() + + def changeEvent(self, event: Optional[QtCore.QEvent]) -> None: + """Re-tint the tree and the Types pane when the application + switches between a light and a dark theme (the tints are stored + on the rows, so they do not follow the palette on their own).""" + super().changeEvent(event) + if event is not None and event.type() in ( + QtCore.QEvent.Type.PaletteChange, + QtCore.QEvent.Type.ApplicationPaletteChange, + ): + # the event can arrive while __init__ is still building the GUI + controller = getattr(self, "typesController", None) + dark = is_dark_theme() + if controller is not None and dark != self._darkTheme: + self._darkTheme = dark + controller.apply_tints() + + def connectSignals(self) -> None: + super().connectSignals() + self.view.delegate.removeParameter.connect(self.removeParameter) + self.addParam.newParamRequested.connect(self.addParameter) + self.parameterCreationError.connect(self.addParam.setError) + self.parameterCreated.connect(self.addParam.clear) + self.profileManager.indexChanged.connect(self.loadProfile) + # the tints run before the Lock pass on a structural change + self.typesController.connectSignals() + self.locksController.connectSignals() + self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) + self.shortcutManager.register("clear_add", self.addParam.clear, self) + self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) + self.shortcutManager.register("load_items", self.loadFromFile, self) + self.shortcutManager.register("save_items", self.saveToFile, self) + self.shortcutManager.register("show_types", self._toggle_tabs, self) + + @QtCore.Slot() + def _deleteCurrentItem(self) -> None: + item = self._getCurrentItem() + if item is not None: + self.removeParameter(item.name) + + def makeToolbar(self) -> QtWidgets.QToolBar: + toolbar = super().makeToolbar() + + toolbar.addSeparator() + + loadParamAction = toolbar.addAction( + QtGui.QIcon(":/icons/load.svg"), + "Load parameters from file", + ) + loadParamAction.triggered.connect(lambda x: self.loadFromFile()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("load_items", loadParamAction) + + saveParamAction = toolbar.addAction( + QtGui.QIcon(":/icons/save.svg"), + "Save parameters to file", + ) + saveParamAction.triggered.connect(lambda x: self.saveToFile()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("save_items", saveParamAction) + + # the Locks panel toggle (plan task 5.4); the toggled connection + # and the shortcut are wired in connectSignals, where the panel + # exists + self.locksAction = toolbar.addAction( + QtGui.QIcon(":/icons/lock.svg"), + "Show the Locks panel", + ) + assert self.locksAction is not None # addAction always returns one + self.locksAction.setCheckable(True) + self.shortcutManager.register_tooltip("toggle_locks", self.locksAction) + + return toolbar + + def refreshAll(self) -> None: + super().refreshAll() + self.instrument.refresh_profiles() + self.profileManager.refresh() + self.state.refresh(self.instrument) + self.typesController.apply_tints() + self.locksController.apply_locks() + + def removeParameter(self, fullName: str) -> None: + """Remove the parameter at ``fullName`` (the row's delete button + and the delete_item shortcut both land here). + + While the parameter is the Target of Locks — deleting it drops + them (D3) — a QMessageBox names every Follower that will lose its + Lock and asks for confirmation (plan task 5.6); Cancel returns + without touching the Server. A parameter without Followers is + removed without a dialog.""" + self.removalDialog = None + if not self.instrument.has_param(fullName): + return + try: + followers = self.instrument.followers_of(fullName) + except Exception: + # the Server call failed: fall back to the client-side list + # computed from the state — locked and unlocked alike, the + # Targets compared through relative_path + followers = sorted( + follower + for follower, lock in self.state.locks.items() + if relative_path(lock.target, self.instrument.name) == fullName + ) + if followers: + lines = [] + for follower in followers: + lock = self.state.locks.get(follower) + state = "locked" if lock is not None and lock.locked else "unlocked" + lines.append(f"{follower} ({state})") + box = QtWidgets.QMessageBox(self) + box.setObjectName("removalDialog") + box.setIcon(QtWidgets.QMessageBox.Icon.Question) + box.setWindowTitle("Remove Target?") + # macOS ignores a QMessageBox's window title (windowTitle() + # reads back empty there); tests pin the dialog through its + # object name and text instead. + box.setText( + f"Removing {fullName} also removes the Locks of:\n" + "\n".join(lines) + ) + box.setStandardButtons( + cast( + "QtWidgets.QMessageBox.StandardButtons", + QtWidgets.QMessageBox.StandardButton.Ok + | QtWidgets.QMessageBox.StandardButton.Cancel, + ) + ) + box.setDefaultButton(QtWidgets.QMessageBox.StandardButton.Cancel) + self.removalDialog = box + clicked = box.exec() + # the box is closed on both paths: the attribute matches its + # docstring again (the tests' QTimer callbacks read it while + # the box is open, so they keep working) + self.removalDialog = None + if clicked != QtWidgets.QMessageBox.StandardButton.Ok: + return + self.instrument.remove_parameter(fullName) + + def addParameter(self, fullName: str, value: Any, unit: str) -> None: + try: + # Validators are commented out until they can be serialized. + self.instrument.add_parameter( + fullName, + initial_value=value, + unit=unit, + ) # vals=vals) + self.parameterCreated.emit() + except Exception as e: + self.parameterCreationError.emit( + f"Could not create parameter.Adding parameter raised{type(e)}: {e.args}" + ) + return + + @QtCore.Slot() + def loadProfile(self) -> None: + profileName = self.profileManager.currentText() + self.instrument.switch_to_profile(profileName) + super().refreshAll() + self.instrument.refresh_profiles() + # a profile load emits no parameter-creation/parameter-deletion + # Broadcasts for the parameters it (re)creates, so the state of the + # Types and Locks must be re-read from the Parameter Manager + self.state.refresh(self.instrument) + self.typesController.apply_tints() + self.locksController.apply_locks() + + def _toggle_tabs(self) -> None: + """The show_types shortcut (plan task 5.6): switch between the + Parameters and Types tabs.""" + self.tabs.setCurrentIndex(1 if self.tabs.currentIndex() == 0 else 0) + + @QtCore.Slot() + def loadFromFile(self, loadFile: Optional[str] = None) -> None: + try: + self.instrument.fromFile(filePath=loadFile, deleteMissing=False) + self.refreshAll() + + except Exception as e: + logger.info(f"Loading failed. {type(e)}: {e.args}") + + @QtCore.Slot() + def saveToFile(self) -> None: + try: + self.instrument.toFile() + except Exception as e: + logger.info(f"Saving failed. {type(e)}: {e.args}") diff --git a/src/instrumentserver/gui/parameters.py b/src/instrumentserver/gui/parameters.py index fe5f4e8..f7573c1 100644 --- a/src/instrumentserver/gui/parameters.py +++ b/src/instrumentserver/gui/parameters.py @@ -180,6 +180,10 @@ def __init__( layout.setContentsMargins(1, 1, 1, 1) self.setLayout(layout) + # Rows render read-only while their Lock is locked (plan task 5.3); + # :meth:`set_read_only` keeps this flag current. + self.read_only = False + @QtCore.Slot() def onReturnPressed(self) -> None: """Activates the setButton when the input is selected and enter is pressed.""" @@ -211,6 +215,23 @@ def setWidgetFromParameter(self) -> None: self._setMethod(val) self.parameterSet.emit(val) + def set_read_only(self, read_only: bool) -> None: + """Render the value read-only while the parameter's Lock is locked + (D3): the input and the set button are disabled, since a locked + Follower refuses ``set``, and the get button stays enabled so the + Target's value still refreshes. A parameter without a set method + was constructed with its set button disabled; it is never + re-enabled here. + + :param read_only: whether editing the value is refused. + """ + self.read_only = read_only + # disabling an AnyInput disables its input field and the eval + # toggle with it; every other kind of paramWidget *is* the input + self.paramWidget.setEnabled(not read_only) + if not isinstance(self.paramWidget, QtWidgets.QLabel): + self.setButton.setEnabled(not read_only) + class AnyInput(QtWidgets.QWidget): #: Signal(str) -- diff --git a/src/instrumentserver/gui/shortcuts.py b/src/instrumentserver/gui/shortcuts.py index 77c8096..5ddb583 100644 --- a/src/instrumentserver/gui/shortcuts.py +++ b/src/instrumentserver/gui/shortcuts.py @@ -43,6 +43,13 @@ class KeyboardShortcutManager: "save_items": ("Ctrl+Shift+S", "Save parameters to JSON file"), "fit_column": ("Ctrl+Shift+D", "Fits column width"), "sort_column": ("Ctrl+D", "Toggle sorting of selected column"), + "toggle_locks": ("Ctrl+Shift+L", "Show or hide the Locks panel"), + "lock_to": ("Ctrl+L", "Lock the selected parameter to… (pick a Target)"), + "unlock_item": ("Ctrl+U", "Unlock the selected parameter"), + "show_types": ( + "Ctrl+Shift+Y", + "Switch between the Parameters and Types tabs", + ), } def __init__(self) -> None: diff --git a/src/instrumentserver/log.py b/src/instrumentserver/log.py index 9819b13..59375a3 100644 --- a/src/instrumentserver/log.py +++ b/src/instrumentserver/log.py @@ -20,29 +20,18 @@ class LogLevels(Enum): debug = auto() -class QLogHandler(QtCore.QObject, logging.Handler): - """A simple log handler that supports logging in TextEdit""" - - COLORS = { - logging.ERROR: QtGui.QColor("red"), - logging.WARNING: QtGui.QColor("orange"), - logging.INFO: QtGui.QColor("green"), - logging.DEBUG: QtGui.QColor("gray"), - } +class _LogBridge(QtCore.QObject): + """The Qt side of :class:`QLogHandler`: the text widget, and the signal + that carries each html line into the GUI thread, where the slot + appends it to the widget.""" #: Signal(str) : Emitted with the html-formatted log record to append to the widget new_html = QtCore.Signal(str) def __init__(self, parent: Optional[QtWidgets.QWidget]) -> None: - QtCore.QObject.__init__(self, parent) - logging.Handler.__init__(self) - + super().__init__(parent) self.widget = QtWidgets.QTextEdit(parent) self.widget.setReadOnly(True) - self._transform: Optional[Callable[[logging.LogRecord, str], Optional[str]]] = ( - None - ) - # connect signal to slot that actually touches the widget (GUI thread) self.new_html.connect(self._append_html) @@ -57,6 +46,31 @@ def _append_html(self, html: str) -> None: self.widget.verticalScrollBar().maximum() # type: ignore[union-attr] ) + +class QLogHandler(logging.Handler): + """A simple log handler that supports logging in TextEdit. + + The handler itself is a plain :class:`logging.Handler`, not a + ``QObject``: :func:`logging.shutdown` visits every handler at + interpreter exit, after Qt has deleted the widgets, and touching a + deleted ``QObject`` there raises. The Qt objects live in a + :class:`_LogBridge` parented to ``parent``.""" + + COLORS = { + logging.ERROR: QtGui.QColor("red"), + logging.WARNING: QtGui.QColor("orange"), + logging.INFO: QtGui.QColor("green"), + logging.DEBUG: QtGui.QColor("gray"), + } + + def __init__(self, parent: Optional[QtWidgets.QWidget]) -> None: + super().__init__() + self._bridge = _LogBridge(parent) + self.widget = self._bridge.widget + self._transform: Optional[Callable[[logging.LogRecord, str], Optional[str]]] = ( + None + ) + def set_transform( self, fn: Callable[[logging.LogRecord, str], Optional[str]] ) -> None: @@ -89,7 +103,7 @@ def emit(self, record: logging.LogRecord) -> None: ) # send to GUI thread - self.new_html.emit(html) + self._bridge.new_html.emit(html) return # fallback: original plain text path @@ -97,15 +111,18 @@ def emit(self, record: logging.LogRecord) -> None: clr_q = self.COLORS.get(record.levelno, QtGui.QColor("black")).name() html = f"{escape(msg)}" - self.new_html.emit(html) + self._bridge.new_html.emit(html) except RuntimeError: # Widget has been destroyed; detach self from the logger so we # stop receiving further records and Python can collect us. + # Assign a new list rather than removeHandler: the logger is + # iterating over its handlers list right now, and removing from + # it in place would skip the handler after this one. for lg in list(logging.Logger.manager.loggerDict.values()) + [ logging.getLogger() ]: if isinstance(lg, logging.Logger) and self in lg.handlers: - lg.removeHandler(self) + lg.handlers = [h for h in lg.handlers if h is not self] class LogWidget(QtWidgets.QWidget): diff --git a/src/instrumentserver/params.py b/src/instrumentserver/params.py index 2d19ce8..36ab13a 100644 --- a/src/instrumentserver/params.py +++ b/src/instrumentserver/params.py @@ -1,15 +1,27 @@ import json import logging import os +from collections.abc import Sequence +from dataclasses import dataclass, field from enum import Enum, auto, unique +from functools import wraps from pathlib import Path -from typing import Any, Dict, List, Union +from typing import Any, Callable, Dict, Iterator, List, Tuple, Union, cast from qcodes import Parameter, validators from qcodes.instrument import InstrumentBase from qcodes.parameters import ParameterBase from . import serialize +from .base import Broadcaster +from .blueprints import ( + PARAMETER_CREATION, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ParameterBroadcastBluePrint, + PMLockBluePrint, + PMTypeBluePrint, +) logger = logging.getLogger(__name__) @@ -56,111 +68,156 @@ def paramTypeFromName(name: str) -> Union[ParameterTypes, None]: return None -class ParameterManager(InstrumentBase): +class ManagedParameter(Parameter): """ - A virtual instrument that acts as a manager for a collection of - arbitrary parameters and groups of parameters. - - Allows extra-easy on-the-fly addition/removal of new parameters. - - For the parameter manager to recognize other profiles in disk, - the profile filename needs to start with 'parameter_manager-' - and end with '.json' with the name of the profile in the middle. - For example, 'parameter_manager-qubit1.json' represents the profile qubit1 + A parameter that can carry a Lock naming another parameter as its Target. + + While the Lock is locked, the parameter answers ``get`` with the Target's + value and refuses ``set`` with a ``ValueError`` naming the Target: values + are pulled on get, nothing is ever pushed into a Follower (ADR-0002). + Locking never touches the own cached value, so unlocking exposes the own + value again. With no Lock, or with a Lock that is present but unlocked, + the parameter behaves like a plain ``Parameter``; an unlocked Lock only + remembers its Target. """ - # TODO: method to instantiate entirely from paramDict - - def __init__(self, name: str) -> None: - super().__init__(name) - - self._workingDirectory = Path(os.getcwd()) - - #: default location and name of the parameters save file. - self.selectedProfile = self.fullProfileName(self.name) - self.profiles: List[str] = [] - self.refresh_profiles() - - self.fromFile() + def __init__(self, name: str, path: str | None = None, **kwargs: Any) -> None: + # The Lock state must exist before ``super().__init__``: creating the + # parameter with an ``initial_value`` already runs ``set_raw``. + self.lock: PMLockBluePrint | None = None + self._target: ParameterBase | None = None + self._path: str | None = path + super().__init__(name, **kwargs) @property - def workingDirectory(self) -> Path: - return self._workingDirectory + def path(self) -> str: + """The parameter's full dotted path with the instrument name, the + form Locks and files use (``parameter_manager.q01.x``); for a + standalone parameter, its plain name.""" + return self._path if self._path is not None else self.name - @workingDirectory.setter - def workingDirectory(self, path: Union[str, Path]) -> None: - self._workingDirectory = Path(path) - self.refresh_profiles() + @property + def locked(self) -> bool: + """Whether a Lock is present and currently locked.""" + return self.lock is not None and self.lock.locked + + def _locked_target(self) -> ParameterBase: + """The Target parameter object of a locked Lock.""" + assert self._target is not None, "a locked Lock has no Target" + return self._target + + def own_value(self) -> Any: + """Return the own cached value, whatever state the Lock is in.""" + return self.cache.get(get_if_invalid=False) + + def get_raw(self) -> Any: + """Answer with the Target's value while locked, the own cached value + otherwise.""" + if self.locked: + return self._locked_target().get() + return self.cache.raw_value + + def set_raw(self, value: Any) -> None: + """Store the value while not locked; while locked, refuse with a + ``ValueError`` naming the full dotted paths of Follower and Target.""" + if self.locked: + assert self.lock is not None, "a locked Lock has no record" + raise ValueError(f"{self.path} is locked to {self.lock.target}") + self.cache._set_from_raw_value(value) + + def _wrap_get(self, get_function: Callable[..., Any]) -> Callable[..., Any]: + """Wrap ``get_raw`` so that a locked get answers with the Target's + value without recording it in the own cache. + + qcodes' get wrapper writes every answered value into the parameter's + cache; for a Follower that would destroy the own value that unlocking + must expose again (ADR-0002). + """ + plain_get = super()._wrap_get(get_function) - def getWorkingDirectory(self): # type: ignore[no-untyped-def] - return self.workingDirectory + @wraps(get_function) + def get_wrapper(*args: Any, **kwargs: Any) -> Any: + if self.locked: + return self._locked_target().get() + return plain_get(*args, **kwargs) - @staticmethod - def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": - """Create a new ParameterManager instance from a paramDict. + return get_wrapper - :param paramDict: The paramDict object. - :param name: Name of the instrument in the paramDict (each entry in the - paramDict starts with .[...]). - :returns: New ParameterManager instance. + def snapshot_base( + self, + update: bool | None = True, + params_to_skip_update: Sequence[str] | None = None, + ) -> Dict[str, Any]: + """Snapshot with a ``lock`` entry while a Lock is present; while + locked, the reported ``value`` is the Target's value. + + With ``update=True`` the base snapshot already asks this parameter, + whose locked get answers with the Target's value, so the Target is + not read a second time. With a falsy ``update`` the Target is read + directly — not its cache — so that each hop of a chain reads + according to its own state (D7). """ - raise NotImplementedError + snap = super().snapshot_base( + update=update, params_to_skip_update=params_to_skip_update + ) + if self.lock is not None: + if self.locked and update is not True: + snap["value"] = self._locked_target().get() + snap["lock"] = {"target": self.lock.target, "locked": self.lock.locked} + return snap - @staticmethod - def cleanProfileName(name: str) -> str: - """ - When passed the full file name of a parameter_manager profile, return only the middle - string representing the profile's name. - """ - return name.replace("parameter_manager-", "").replace(".json", "") - @staticmethod - def fullProfileName(name: str) -> str: - """ - Adds 'parameter_manager-' to the beginning of `name` and adds '.json' at the end. - """ +class ParameterGroup(InstrumentBase): + """ + A Parameter Group: a plain container of parameters and nested Parameter + Groups inside a Parameter Manager. + + It holds parameters and nested Parameter Groups and offers the tree + helpers (dotted-path add/remove/get/set, listing, tree building), but + has no file, profile, Type or Lock logic of its own. Every submodule + of a Parameter Manager is a Parameter Group; only the root is the + Parameter Manager, which extends the Parameter Group with those + responsibilities. When a Parameter Group belongs to a Parameter + Manager, its public ``add_parameter``/``remove_parameter`` route to + the root, so ``ManagedParameter`` creation and the Lock cleanup always + happen; a standalone Parameter Group behaves as a plain container. + """ - if not name.startswith("parameter_manager-"): - name = "parameter_manager-" + name - if not name.endswith(".json"): - name += ".json" - return name + #: The root Parameter Manager this Parameter Group belongs to, or + #: ``None`` for a standalone Parameter Group or the root itself. When + #: set, the public add/remove methods route to the root (D15): only + #: the root owns ``ManagedParameter`` creation and Lock logic. + _root: "ParameterManager | None" = None + + #: This Parameter Group's dotted path relative to its root Parameter + #: Manager, with a trailing dot (``"q01.ro."``); empty on the root and + #: on standalone Parameter Groups. Set together with ``_root`` at + #: creation and used to build root-relative paths when routing. + _path_prefix: str = "" @classmethod - def _to_tree(cls, pm: "ParameterManager") -> Dict: + def _to_tree(cls, pm: "ParameterGroup") -> Dict: ret: dict[str, Any] = {} for smn, sm in pm.submodules.items(): - assert isinstance(sm, ParameterManager) + assert isinstance(sm, ParameterGroup) ret[smn] = cls._to_tree(sm) for pn, p in pm.parameters.items(): ret[pn] = p return ret - @classmethod - def does_profile_exist(cls, profiles: List[str], target: str) -> bool: - found = False - for profile in profiles: - if target in profile: - found = True - break - return found - - def refresh_profiles(self) -> List[str]: - """ - Goes into the working directory and updates the list of profiles. - - :return: List of profiles in the working directory - """ - profiles = [] - for filename in os.listdir(self.workingDirectory): - if filename.startswith("parameter_manager") and filename.endswith(".json"): - profiles.append(filename) - - self.profiles = profiles - return profiles - def to_tree(self) -> Dict: - return ParameterManager._to_tree(self) + return ParameterGroup._to_tree(self) + + def _iter_params(self) -> Iterator[Tuple[str, ParameterBase]]: + """Yield ``(relative dotted path, parameter)`` for every parameter + in this Parameter Group and its nested Parameter Groups, in tree + order. The paths are relative to this group.""" + for pname, param in self.parameters.items(): + yield pname, param + for smn, sm in self.submodules.items(): + assert isinstance(sm, ParameterGroup) + for path, param in sm._iter_params(): + yield f"{smn}.{path}", param def _get_param(self, param_name: str) -> ParameterBase: parent = self._get_parent(param_name) @@ -172,26 +229,37 @@ def _get_param(self, param_name: str) -> ParameterBase: def _get_parent( self, param_name: str, create_parent: bool = False - ) -> "ParameterManager": + ) -> "ParameterGroup": split_names = param_name.split(".") parent = self full_name = self.name + prefix = self._path_prefix for i, n in enumerate(split_names[:-1]): full_name += f".{n}" + prefix += f"{n}." if n in parent.parameters: raise ValueError( f"{n} is a parameter, and cannot have child parameters." ) if n not in parent.submodules: if create_parent: - parent.add_submodule(n, ParameterManager(n)) # type: ignore[type-var] + new_group = ParameterGroup(n) + new_group._root = self._root_for_new_groups() + new_group._path_prefix = prefix + parent.add_submodule(n, new_group) # type: ignore[type-var] else: raise ValueError(f"{n} does not exist.") parent = parent.submodules[n] # type: ignore[assignment] return parent + def _root_for_new_groups(self) -> "ParameterManager | None": + """The root Parameter Manager that nested Parameter Groups created + under this group belong to: this group's root, or ``None`` when + this group is standalone.""" + return self._root + def has_param(self, param_name: str) -> bool: try: self._get_param(param_name) @@ -202,6 +270,12 @@ def has_param(self, param_name: str) -> bool: def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] """Add a parameter. + A Parameter Group that belongs to a Parameter Manager routes the + call to the root, with the path made relative to it + (``.``), so the parameter is created as a + :class:`ManagedParameter` that can carry a Lock (D15). A + standalone Parameter Group creates a plain qcodes ``Parameter``. + :param name: Name of the parameter. If the name contains `.`s, then an element before a dot is interpreted as a submodule. Multiple dots represent nested submodules. I.e., when @@ -211,22 +285,43 @@ def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] :param kw: Any keyword arguments will be passed on to qcodes.Instrument.add_parameter, except: - ``set_cmd`` is always set to ``None`` - - ``parameter_class`` is ``qcodes.Parameter`` + - ``parameter_class`` defaults to ``qcodes.Parameter`` - ``vals`` defaults to ``qcodes.utils.validators.Anything()``. :return: None. """ - kw["parameter_class"] = Parameter + if self._root is not None: + self._root.add_parameter(f"{self._path_prefix}{name}", **kw) + return + kw.setdefault("parameter_class", Parameter) if "vals" not in kw: kw["vals"] = validators.Anything() kw["set_cmd"] = None parent = self._get_parent(name, create_parent=True) - if parent is self: - super().add_parameter(name.split(".")[-1], **kw) - else: - parent.add_parameter(name.split(".")[-1], **kw) + parent._add_own_parameter(name.split(".")[-1], **kw) + + def _add_own_parameter(self, name: str, **kw: Any) -> None: + """Create a parameter directly on this Parameter Group, without + routing: the plain creation path the root's methods end in.""" + super().add_parameter(name, **kw) def remove_parameter(self, param_name: str, cleanup: bool = True) -> None: + """Remove a parameter. + + A Parameter Group that belongs to a Parameter Manager routes the + call to the root, with the path made relative to it, so the + root's Lock cleanup happens before the deletion (D3, D15). A + standalone Parameter Group deletes directly. + + :param param_name: Name of the parameter; dotted names traverse + nested Parameter Groups. + :param cleanup: Whether to remove emptied submodules afterwards. + """ + if self._root is not None: + self._root.remove_parameter( + f"{self._path_prefix}{param_name}", cleanup=cleanup + ) + return parent = self._get_parent(param_name) pname = param_name.split(".")[-1] del parent.parameters[pname] @@ -261,12 +356,6 @@ def purge(parent: InstrumentBase) -> None: purge(self) - def remove_all_parameters(self) -> None: - """Remove all parameters from the instrument.""" - for param in self.list(): - self.remove_parameter(param, cleanup=False) - self.remove_empty_submodules() - def parameter(self, name: str) -> ParameterBase: """Get a parameter object from the manager. @@ -290,12 +379,2091 @@ def tolist(x: Dict[str, Any]) -> List[str]: return tolist(tree) + +@dataclass +class _TypeEntry: + """One entry of a Type: a relative parameter path with its default + value and unit, plus the Target of the entry's Type Lock (``None`` + until a Type Lock is declared on it).""" + + default: Any = None + unit: str = "" + target: str | None = None + + +@dataclass +class _TypeDefinition: + """The Type registry's record of a Type: its name, its entries by + relative parameter path, and its Nested Types as a mapping from the + submodule name that requires them to the nested Type's name.""" + + name: str + parameters: Dict[str, _TypeEntry] = field(default_factory=dict) + nested: Dict[str, str] = field(default_factory=dict) + + +class ParameterManager(Broadcaster, ParameterGroup): + """ + A virtual instrument that acts as a manager for a collection of + arbitrary parameters and groups of parameters. + + Allows extra-easy on-the-fly addition/removal of new parameters. + + The Parameter Manager is the root of the parameter tree. It extends the + Parameter Group with file, profile, Lock, and Type logic; + its submodules are plain Parameter Groups. + + It implements the Broadcaster contract, so the Server can register + itself as a broadcast sink when the Parameter Manager joins the + Station. Every Lock method that changes a Lock emits one + ``pm-lock-update`` Broadcast per affected Follower (D10), and + ``remove_parameter`` emits them for the Locks that deleting a Target + drops. Every Type-editing method emits ``pm-type-update`` with the + edited Type's fresh ``PMTypeBluePrint`` (D22), and the parameters the + Type edits and :meth:`add_instance` create as side effects are + re-emitted as ``parameter-creation`` Broadcasts (ADR-0003); direct + ``add_parameter`` calls keep being announced by the Server, so nothing + is announced twice. Declaring a Type Lock with + :meth:`lock_type_parameter` and removing it with + :meth:`unlock_type_parameter` do both: they emit the + ``pm-lock-update`` Broadcasts of the Locks the declaration creates and + the ``pm-type-update`` of the edited Type, and every new Instance gets + the existing Type Locks of its Type at creation (D17). Removing a + parameter that is the stored Target of Type Locks drops the Locks + pointing at it and clears those Type Locks, emitting one + ``pm-type-update`` per affected Type (D18). + + For the parameter manager to recognize other profiles in disk, + the profile filename needs to start with 'parameter_manager-' + and end with '.json' with the name of the profile in the middle. + For example, 'parameter_manager-qubit1.json' represents the profile qubit1 + """ + + # TODO: method to instantiate entirely from paramDict + + def __init__(self, name: str) -> None: + super().__init__(name) + + # The Type registry: maps each Type name to its ``_TypeDefinition``. + # It lives on the root Parameter Manager only (D15); Parameter + # Groups hold no Types. + self._types: Dict[str, _TypeDefinition] = {} + + self._workingDirectory = Path(os.getcwd()) + + #: default location and name of the parameters save file. + self.selectedProfile = self.fullProfileName(self.name) + self.profiles: List[str] = [] + self.refresh_profiles() + + self.fromFile() + + @property + def workingDirectory(self) -> Path: + return self._workingDirectory + + @workingDirectory.setter + def workingDirectory(self, path: Union[str, Path]) -> None: + self._workingDirectory = Path(path) + self.refresh_profiles() + + def getWorkingDirectory(self): # type: ignore[no-untyped-def] + return self.workingDirectory + + def add_parameter(self, name: str, **kw: Any) -> None: # type: ignore[override] + """Add a parameter, created as a :class:`ManagedParameter`. + + Same dotted-name semantics as :meth:`ParameterGroup.add_parameter`, + which this method calls; only the parameter class differs, so that + the Parameter Manager's parameters can carry a Lock. The created + parameter's ``path`` is set to its full dotted path with the + instrument name (``parameter_manager.q01.x``), the form Locks and + files use. + + Raises ``ValueError`` naming the offending path, creating nothing, + when ``name`` is the reserved Globals name ``_globals`` or starts + with it (D18): the Globals submodule holds only the default + Targets of Type Locks, and its parameters are created on demand by + :meth:`_ensure_global_target`, not through the public API. Since a + Parameter Group routes its ``add_parameter`` to the root (D15), + this check covers calls made on any Parameter Group of this + Parameter Manager as well. + + :param name: Name of the parameter; see + :meth:`ParameterGroup.add_parameter`. + :param kw: Any keyword arguments will be passed on to + qcodes.Instrument.add_parameter, as in + :meth:`ParameterGroup.add_parameter`. + :return: None. + """ + # validate-then-mutate: the Globals refusal runs before anything + # is created (rule 3) + if name == "_globals" or name.startswith("_globals."): + raise ValueError( + f"'{name}' is not a valid parameter path: " + "the Globals submodule name is reserved" + ) + kw["parameter_class"] = ManagedParameter + kw["path"] = f"{self.name}.{name}" + super().add_parameter(name, **kw) + + def _create_managed_parameter( + self, path: str, initial_value: Any, unit: str + ) -> None: + """Create the parameter at the dotted path ``path`` (relative to + this Parameter Manager) through the internal creation path — + ``_get_parent(..., create_parent=True)`` + + ``_add_own_parameter`` — as a :class:`ManagedParameter` whose + ``path`` is the full dotted form with the instrument name, exactly + like the public :meth:`add_parameter` creates its parameters. + Missing Parameter Groups on the way are created; a segment of + ``path`` that is an existing parameter raises ``ValueError`` + (through :meth:`_get_parent`). + + This is the shared creation path of the callers that must bypass + the public :meth:`add_parameter` refusal of the Globals name + (D18): :meth:`_ensure_global_target`, which creates the default + Target of a Type Lock declaration, and :meth:`fromParamDict`, + which creates the missing parameters a profile file asks for, + Globals parameters included. It emits nothing: the callers own + their Broadcasts. + """ + parent = self._get_parent(path, create_parent=True) + parent._add_own_parameter( + path.split(".")[-1], + parameter_class=ManagedParameter, + path=self._full_path(path), + initial_value=initial_value, + unit=unit, + ) + + def _root_for_new_groups(self) -> "ParameterManager": + """Parameter Groups created under the Parameter Manager belong to + it: it is their root.""" + return self + + def remove_parameter(self, param_name: str, cleanup: bool = True) -> None: + """Remove a parameter, first removing every Lock whose Target it is + (ADR-0002): the Followers become plain parameters and answer ``get`` + with their own values again. One ``pm-lock-update`` Broadcast with a + ``None`` value is emitted per dropped Lock (D10). + + When the removed parameter is the stored Target of one or more Type + Locks — a Globals parameter, or any parameter an explicit Type Lock + points at — the Type Lock is cleared as well (D18): every own entry + of every Type whose stored Target it was gets ``target=None``, and + one ``pm-type-update`` Broadcast per affected Type, in registry + order and carrying the Type's fresh :class:`PMTypeBluePrint`, is + emitted after the ``pm-lock-update``s (D22). The Locks a cleared + Type Lock had put on Instance parameters are ordinary Locks + pointing at the removed parameter, so the same cleanup drops them; + a parameter that is no Type Lock Target emits no + ``pm-type-update``. + + Same signature and deletion behaviour as + :meth:`ParameterGroup.remove_parameter`; the path is relative to + this Parameter Manager. The deletion itself emits nothing here: + the Server announces a direct ``remove_parameter`` call with a + ``parameter-deletion`` Broadcast (ADR-0003). + """ + # validate-then-mutate: the parameter must exist before any Lock or + # Type Lock is touched. The checks mirror what the deletion itself + # would raise. + parent = self._get_parent(param_name) + pname = param_name.split(".")[-1] + if pname not in parent.parameters: + raise KeyError(pname) + + # every Lock pointing at the removed parameter goes away with it, + # locked or not: an unlocked Lock must not keep remembering a + # Target that no longer exists. + target_full = self._full_path(param_name) + dropped_followers: List[str] = [] + for rel_path, param in self._iter_params(): + lock = getattr(param, "lock", None) + if lock is not None and lock.target == target_full: + assert isinstance(param, ManagedParameter) + param.lock = None + param._target = None + dropped_followers.append(rel_path) + + # every Type Lock whose stored Target the removed parameter was is + # cleared with it (D18): the entry keeps no Target that points + # nowhere, so no later Instance is locked to a missing parameter. + # Own entries only: a nesting Type's effective set carries units + # and defining Types, not Targets, so only the Type owning the + # entry is affected (like lock_type_parameter). + affected_types: List[str] = [] + for type_name, definition in self._types.items(): + touched = False + for entry in definition.parameters.values(): + if entry.target == target_full: + entry.target = None + touched = True + if touched: + affected_types.append(type_name) + + # broadcasts after the mutation, in order: one pm-lock-update with + # None per dropped Follower (D10), then one pm-type-update per + # affected Type, in registry order (D22); the deletion itself + # emits nothing here + for rel_path in dropped_followers: + self._broadcast_lock_update(rel_path, None) + for type_name in affected_types: + self._broadcast_type_update(type_name) + + super().remove_parameter(param_name, cleanup) + + # ------------------------------------------------------------------ + # Lock API (plan decision D9) + # + # A Lock lives on the Follower's :class:`ManagedParameter`: its + # :class:`PMLockBluePrint` records the Target as a full dotted path + # with the instrument name and whether the Lock is currently locked. + # Every method validates first and raises before touching anything + # (no partial state on error). Paths passed in by name and returned + # by name are dotted paths relative to this Parameter Manager; only + # ``PMLockBluePrint.target`` and the stored Lock Target use the full + # form, as :attr:`ManagedParameter.path` does. + # + # Every method that changes a Lock emits one ``pm-lock-update`` + # Broadcast per affected Follower (D10), through :meth:`broadcast` of + # the Broadcaster contract. Broadcasts that only report state are + # emitted after the change; failed validations and the logged no-op + # paths (unlock on an unlocked, relock on a locked Lock) emit nothing. + + def _broadcast_lock_update( + self, follower_path: str, lock: "PMLockBluePrint | None" + ) -> None: + """Emit one ``pm-lock-update`` Broadcast about the Follower at + ``follower_path`` (relative to this Parameter Manager): the payload + is its :class:`PMLockBluePrint`, or ``None`` when its Lock was + removed (D10). With no sink registered, :meth:`broadcast` is a + no-op, so standalone use of the Parameter Manager emits nothing.""" + self.broadcast( + ParameterBroadcastBluePrint( + name=self._full_path(follower_path), + action=PM_LOCK_UPDATE, + value=lock, + ) + ) + + def _broadcast_type_update(self, type_name: str) -> None: + """Emit one ``pm-type-update`` Broadcast about the Type ``type_name`` + (D22): ``name`` is the Type's full dotted name and the payload is + its fresh :class:`PMTypeBluePrint`, so a GUI can replace that one + Type locally with no follow-up fetch. With no sink registered, + :meth:`broadcast` is a no-op, so standalone use of the Parameter + Manager emits nothing.""" + self.broadcast( + ParameterBroadcastBluePrint( + name=f"{self.name}.{type_name}", + action=PM_TYPE_UPDATE, + value=self.get_type(type_name), + ) + ) + + def _broadcast_parameter_creation( + self, parameter_path: str, value: Any, unit: str + ) -> None: + """Re-emit one ``parameter-creation`` Broadcast for a parameter this + Parameter Manager created as a side effect of a Type edit or of + :meth:`add_instance` (D22, ADR-0003), in the same shape the Server + emits for a direct ``add_parameter`` call: the full dotted path as + ``name``, the initial value as ``value`` and the unit as ``unit``. + Direct ``add_parameter`` calls are announced by the Server and emit + nothing here, so nothing is announced twice.""" + self.broadcast( + ParameterBroadcastBluePrint( + name=self._full_path(parameter_path), + action=PARAMETER_CREATION, + value=value, + unit=unit, + ) + ) + + def _full_path(self, relative_name: str) -> str: + """The full dotted path (with the instrument name) of a path + relative to this Parameter Manager.""" + return f"{self.name}.{relative_name}" + + def _resolve_param(self, name: str) -> ParameterBase: + """The parameter object at a dotted path relative to this + Parameter Manager; raises ``ValueError`` naming the path when no + parameter exists there.""" + try: + parent = self._get_parent(name) + except ValueError: + raise ValueError(f"Parameter '{name}' does not exist") from None + pname = name.split(".")[-1] + if pname not in parent.parameters: + raise ValueError(f"Parameter '{name}' does not exist") + return parent.parameters[pname] + + def _param_by_full_path(self, full_path: str) -> ParameterBase | None: + """The parameter object at a full dotted path (the form a Lock's + Target is stored in), or ``None`` when the path points outside + this Parameter Manager or no parameter exists there.""" + prefix = f"{self.name}." + if not full_path.startswith(prefix): + return None + try: + return self._get_param(full_path[len(prefix) :]) + except ValueError: + return None + + def _require_lock(self, param: ParameterBase, name: str) -> PMLockBluePrint: + """The Lock record of a Follower; raises ``ValueError`` naming the + path when the parameter carries no Lock.""" + lock = getattr(param, "lock", None) + if lock is None: + raise ValueError(f"{self._full_path(name)} has no Lock") + return lock + + def _check_lock_allowed(self, follower_full: str, target_full: str) -> None: + """Raise ``ValueError`` for a self-lock, or when locking would + close a cycle: the walk follows each Target's Lock regardless of + locked/unlocked state (D7).""" + if follower_full == target_full: + raise ValueError(f"cannot lock {follower_full} to itself") + chain = [target_full] + seen = {target_full} + current = target_full + while True: + param = self._param_by_full_path(current) + if param is None: + break + lock = getattr(param, "lock", None) + if lock is None: + break + nxt = lock.target + if nxt == follower_full or nxt in seen: + raise ValueError( + f"cannot lock {follower_full} to {target_full}: cycle in " + f"Lock targets: {' -> '.join(chain + [nxt])}" + ) + seen.add(nxt) + chain.append(nxt) + current = nxt + + def lock(self, name: str, target: str) -> None: + """Lock the parameter at ``name`` to the parameter at ``target`` + (dotted paths relative to this Parameter Manager). + + Creates the Lock, or re-targets it when one exists already, and + locks it: while locked, ``name`` answers ``get`` with the Target's + value and refuses ``set`` (ADR-0002). The Target must be a + parameter of this same Parameter Manager (D8). Raises + ``ValueError`` naming every offending path when a path does not + exist (both paths are checked before one error is raised), the + Follower cannot carry a Lock, the Lock would be a self-lock, or it + would close a cycle (walking Targets regardless of locked/unlocked + state, D7). On success emits one ``pm-lock-update`` Broadcast + naming the Follower with its new Lock (D10); a failed validation + emits nothing. + + :param name: path of the Follower. + :param target: path of the Target. + """ + # validate-then-mutate: resolve both paths up front and name every + # missing one in a single error (rule 3) + resolved: List[ParameterBase] = [] + missing: List[str] = [] + for path in (name, target): + try: + resolved.append(self._resolve_param(path)) + except ValueError as exc: + missing.append(str(exc)) + if missing: + raise ValueError("; ".join(missing)) + follower, target_param = resolved + follower_full = self._full_path(name) + target_full = self._full_path(target) + if not isinstance(follower, ManagedParameter): + raise ValueError(f"{follower_full} cannot carry a Lock") + self._check_lock_allowed(follower_full, target_full) + follower._target = target_param + follower.lock = PMLockBluePrint(target=target_full, locked=True) + # a snapshot, not the stored record: sinks must not see the payload + # change when the Lock is toggled or re-targeted later + self._broadcast_lock_update( + name, PMLockBluePrint(target=target_full, locked=True) + ) + + def unlock(self, name: str) -> None: + """Unlock the Lock of the parameter at ``name`` (dotted path + relative to this Parameter Manager): it keeps remembering its + Target but answers ``get`` with its own value again (D5). Raises + ``ValueError`` naming the path when the parameter does not exist + or carries no Lock; unlocking an already unlocked Lock does + nothing and logs at INFO level. On a state change emits one + ``pm-lock-update`` Broadcast carrying the unlocked Lock (D10); + the no-op path emits nothing.""" + param = self._resolve_param(name) + lock = self._require_lock(param, name) + if not lock.locked: + logger.info(f"{self._full_path(name)} is already unlocked; nothing to do") + return + lock.locked = False + # a snapshot, not the live record: sinks must not see the payload + # change when the Lock is toggled again + self._broadcast_lock_update( + name, PMLockBluePrint(target=lock.target, locked=lock.locked) + ) + + def relock(self, name: str) -> None: + """Lock the Lock of the parameter at ``name`` (dotted path + relative to this Parameter Manager) to its remembered Target again + (D5). Raises ``ValueError`` naming the paths when the parameter + does not exist, carries no Lock, or when the remembered Target is + gone or locking to it would close a cycle (D7); relocking an + already locked Lock does nothing and logs at INFO level. On a + state change emits one ``pm-lock-update`` Broadcast carrying the + locked Lock (D10); the no-op path emits nothing.""" + param = self._resolve_param(name) + lock = self._require_lock(param, name) + follower_full = self._full_path(name) + if lock.locked: + logger.info(f"{follower_full} is already locked; nothing to do") + return + target_param = self._param_by_full_path(lock.target) + if target_param is None: + raise ValueError( + f"{follower_full} remembers Target {lock.target}, which does not exist" + ) + self._check_lock_allowed(follower_full, lock.target) + assert isinstance(param, ManagedParameter) + param._target = target_param + lock.locked = True + # a snapshot, not the live record: sinks must not see the payload + # change when the Lock is toggled again + self._broadcast_lock_update( + name, PMLockBluePrint(target=lock.target, locked=lock.locked) + ) + + def toggle_lock(self, name: str) -> None: + """Toggle the Lock of the parameter at ``name`` (dotted path + relative to this Parameter Manager): locked becomes unlocked and + unlocked becomes locked again (D5). Raises ``ValueError`` naming + the path when the parameter does not exist or carries no Lock, and + like :meth:`relock` when locking back would close a cycle. Emits + one ``pm-lock-update`` Broadcast through :meth:`unlock` / + :meth:`relock`, which carry out the change (D10).""" + param = self._resolve_param(name) + lock = self._require_lock(param, name) + if lock.locked: + self.unlock(name) + else: + self.relock(name) + + def remove_lock(self, name: str) -> None: + """Remove the Lock of the parameter at ``name`` (dotted path + relative to this Parameter Manager) entirely: the Target is + forgotten and the parameter behaves as a plain parameter again + (D5). Raises ``ValueError`` naming the path when the parameter + does not exist or carries no Lock. Emits one ``pm-lock-update`` + Broadcast with a ``None`` value for the Follower whose Lock was + removed (D10).""" + param = self._resolve_param(name) + self._require_lock(param, name) + assert isinstance(param, ManagedParameter) + param.lock = None + param._target = None + self._broadcast_lock_update(name, None) + + def get_lock(self, name: str) -> "PMLockBluePrint | None": + """The Lock of the parameter at ``name`` (dotted path relative to + this Parameter Manager) as a :class:`PMLockBluePrint` whose Target + is the full dotted path, or ``None`` when it carries no Lock. + Raises ``ValueError`` naming the path when the parameter does not + exist.""" + param = self._resolve_param(name) + lock = getattr(param, "lock", None) + if lock is None: + return None + return PMLockBluePrint(target=lock.target, locked=lock.locked) + + def list_locks(self) -> "Dict[str, PMLockBluePrint]": + """All Locks in this Parameter Manager, locked and unlocked alike + (D5), as a mapping from the Follower's path (relative to this + Parameter Manager) to its :class:`PMLockBluePrint`.""" + locks: Dict[str, PMLockBluePrint] = {} + for rel_path, param in self._iter_params(): + lock = getattr(param, "lock", None) + if lock is not None: + locks[rel_path] = PMLockBluePrint( + target=lock.target, locked=lock.locked + ) + return locks + + def followers_of(self, name: str) -> "List[str]": + """Paths (relative to this Parameter Manager) of every Follower + whose Lock points at the parameter at ``name``, locked and + unlocked alike. Raises ``ValueError`` naming the path when the + parameter does not exist.""" + self._resolve_param(name) + target_full = self._full_path(name) + followers: List[str] = [] + for rel_path, param in self._iter_params(): + lock = getattr(param, "lock", None) + if lock is not None and lock.target == target_full: + followers.append(rel_path) + return followers + + # ------------------------------------------------------------------ + # Type API (plan decisions D11, D15, D16) + # + # The Type registry (``self._types``) lives on the root Parameter + # Manager only: it maps each Type name to its ``_TypeDefinition``. A + # Type's entries are relative dotted paths with a default value and a + # unit; its Nested Types map the submodule name that requires them to + # the nested Type's name. Type names are refused for the reserved + # Globals submodule ``_globals``. Methods validate before mutating and + # name every offending path or Type in an error. + # + # Every Type-editing method emits its Broadcasts only after the whole + # mutation succeeded (D22): one ``parameter-creation`` Broadcast per + # parameter the edit created as a side effect, in creation order, + # followed by one ``pm-type-update`` Broadcast per affected Type — the + # edited Type first, then every Type nesting it, whose effective + # parameter set the edit changed too — carrying that Type's fresh + # ``PMTypeBluePrint``; ``remove_type`` emits exactly one + # ``pm-type-update`` with a ``None`` payload. ``set_type_parameter_default`` + # changes no effective set, so only the edited Type is named. The + # read-only queries (``list_types``, ``get_type``, ``instances_of``, + # ``types_of``) and failed validations emit nothing. + # ------------------------------------------------------------------ + + def _require_type( + self, name: str, types: "Dict[str, _TypeDefinition] | None" = None + ) -> "_TypeDefinition": + """The registry entry of the Type ``name``; raises ``ValueError`` + naming the name when no such Type exists. ``types`` defaults to + the Type registry; the version-2 document reader passes the + candidate registry built from the document, so the expansion + helpers can run on it without touching the real one.""" + if types is None: + types = self._types + try: + return types[name] + except KeyError: + raise ValueError(f"no Type named '{name}' exists") from None + + def add_type(self, name: str) -> None: + """Create an empty Type named ``name`` in the Type registry. + + Raises ``ValueError`` naming the name when it is the reserved + Globals name ``_globals`` or when a Type with that name exists + already; nothing is changed then. A fresh Type has no entries and + no Nested Types, so it has no Instances until entries are added. + Emits one ``pm-type-update`` Broadcast carrying the new Type's + :class:`PMTypeBluePrint` after it is created (D22); a failed + validation emits nothing. + + :param name: Name of the Type. + """ + # validate-then-mutate: both refusals are checked before the + # registry is touched + if name == "_globals": + raise ValueError( + f"'{name}' is not a valid Type name: " + "the Globals submodule name is reserved" + ) + if name in self._types: + raise ValueError(f"a Type named '{name}' already exists") + self._types[name] = _TypeDefinition(name=name) + self._broadcast_type_update(name) + + def remove_type(self, name: str) -> None: + """Remove the Type ``name`` from the Type registry. + + The parameters of Instances are untouched (D13). Raises + ``ValueError`` when no such Type exists, and — naming every Type + that nests it — while any other Type still requires ``name`` as a + Nested Type; nothing is removed then. Emits exactly one + ``pm-type-update`` Broadcast with a ``None`` payload after the + Type is removed (D22): nobody nests it, so no other Type is + affected; a failed validation emits nothing. + + :param name: Name of the Type. + """ + self._require_type(name) + nesting = sorted( + definition.name + for definition in self._types.values() + if name in definition.nested.values() + ) + if nesting: + nesters = ", ".join(f"'{other}'" for other in nesting) + raise ValueError( + f"cannot remove Type '{name}': nested in Type(s) {nesters}" + ) + del self._types[name] + self.broadcast( + ParameterBroadcastBluePrint( + name=f"{self.name}.{name}", action=PM_TYPE_UPDATE, value=None + ) + ) + + def list_types(self) -> List[str]: + """Names of every Type in the Type registry.""" + return list(self._types) + + def get_type(self, name: str) -> "PMTypeBluePrint": + """The Type ``name`` as a :class:`PMTypeBluePrint`: its own + entries as ``{path: {default, unit, target}}``, its Nested Types + as ``{submodule: type}``, and the computed effective parameter + set as ``{path: {unit, from_type}}``. Raises ``ValueError`` + naming the name when no such Type exists, and like + :meth:`_effective_parameters` when its Nested Types cycle or its + effective set contains a path twice.""" + definition = self._require_type(name) + return PMTypeBluePrint( + name=definition.name, + parameters={ + path: { + "default": entry.default, + "unit": entry.unit, + "target": entry.target, + } + for path, entry in definition.parameters.items() + }, + nested=dict(definition.nested), + effective=self._effective_parameters(name), + ) + + def _effective_parameters(self, type_name: str) -> Dict[str, Dict[str, str]]: + """The effective parameter set of the Type ``type_name`` (D11): + every entry path of the Type itself and of its Nested Types, + expanded recursively under the submodule name that requires them, + mapped to ``{"unit": , "from_type": }``. + + Raises ``ValueError`` naming the cycle when the Nested Types + reachable from ``type_name`` form a cycle, naming both Type names + when a Nested Type is missing from the registry, and — naming + every offending path — when an entry path appears twice in the + expanded set.""" + expanded = self._expand_effective(type_name) + return { + path: {"unit": entry.unit, "from_type": from_type} + for path, (entry, from_type) in expanded.items() + } + + def _expand_effective( + self, + type_name: str, + types: "Dict[str, _TypeDefinition] | None" = None, + ) -> Dict[str, Tuple["_TypeEntry", str]]: + """The effective parameter set of the Type ``type_name`` in raw + form: every expanded path mapped to the :class:`_TypeEntry` that + defines it and the name of the Type defining it. Raises the same + errors as :meth:`_effective_parameters` (unknown Type, a cycle, + a Nested Type missing from the registry, a path appearing twice). + ``types`` defaults to the Type registry; the version-2 document + reader passes the candidate registry built from the document, the + way :meth:`add_nested_type` validates a candidate.""" + definition = self._require_type(type_name, types) + # cycles first: the expansion below would not terminate + cycle = self._nested_cycle(definition, types=types) + if cycle is not None: + raise ValueError(f"cycle in nested Types: {' -> '.join(cycle)}") + # the cycle walk visited every Nested Type of the closure, so all + # lookups below are known to exist + expanded: Dict[str, Tuple[_TypeEntry, str]] = {} + duplicated: List[str] = [] + self._collect_effective(definition, "", expanded, duplicated, types=types) + if duplicated: + paths = ", ".join(f"'{path}'" for path in sorted(duplicated)) + raise ValueError( + f"parameter path(s) {paths} appear more than once in the " + f"effective set of Type '{type_name}'" + ) + return expanded + + def _effective_entries(self, type_name: str) -> Dict[str, _TypeEntry]: + """The effective parameter set of the Type ``type_name`` carrying + the full :class:`_TypeEntry` (default value, unit, Type Lock + Target) of the entry that defines each path: what writing the set + into the tree as parameters needs. Raises like + :meth:`_effective_parameters`.""" + return { + path: entry + for path, (entry, _) in self._expand_effective(type_name).items() + } + + def _nested_cycle( + self, + definition: "_TypeDefinition", + types: Dict[str, "_TypeDefinition"] | None = None, + ) -> "List[str] | None": + """The chain of Type names of the first cycle among the Nested + Types reachable from ``definition`` (the chain starts and ends + with the same Type), or ``None`` when none is reachable. Raises + ``ValueError`` naming both names when a Nested Type is not in + ``types``. The walk follows each branch with its own chain, so + nesting the same Type at several submodules is not a cycle. + ``types`` defaults to the Type registry; ``add_nested_type`` + passes a copied registry holding a candidate definition so it can + refuse a cycle before mutating anything.""" + if types is None: + types = self._types + + def walk(defn: _TypeDefinition, chain: List[str]) -> List[str] | None: + for nested_name in defn.nested.values(): + if nested_name in chain: + return chain[chain.index(nested_name) :] + [nested_name] + nested = types.get(nested_name) + if nested is None: + raise ValueError( + f"Type '{defn.name}' nests '{nested_name}', " + "which does not exist" + ) + cycle = walk(nested, chain + [nested_name]) + if cycle is not None: + return cycle + return None + + return walk(definition, [definition.name]) + + def _collect_effective( + self, + definition: "_TypeDefinition", + prefix: str, + effective: Dict[str, Tuple[_TypeEntry, str]], + duplicated: List[str], + types: "Dict[str, _TypeDefinition] | None" = None, + ) -> None: + """Add every entry of ``definition`` — and, recursively, of its + Nested Types under their submodule names — to ``effective`` as + ``(entry, defining Type name)`` pairs, recording every path that + appears more than once in ``duplicated`` instead of raising, so + one error can name them all. ``types`` defaults to the Type + registry; see :meth:`_expand_effective`.""" + if types is None: + types = self._types + for path, entry in definition.parameters.items(): + full_path = f"{prefix}{path}" + if full_path in effective: + duplicated.append(full_path) + else: + effective[full_path] = (entry, definition.name) + for submodule, nested_name in definition.nested.items(): + self._collect_effective( + types[nested_name], + f"{prefix}{submodule}.", + effective, + duplicated, + types=types, + ) + + # ------------------------------------------------------------------ + # Instance matching (plan decisions D12, D16; ADR-0001) + # + # Instances are duck-typed: a Parameter Group is an Instance of a Type + # because it carries every path of the Type's effective parameter set, + # each with the unit the Type declares; nothing stores membership and + # matching walks the tree on every query. The root is never an + # Instance, and the reserved Globals submodule ``_globals`` — wherever + # it appears in the tree — and everything under it are excluded from + # matching. ``instances_of`` and ``types_of`` are read-only queries: + # they change no state and emit no Broadcast. + # ------------------------------------------------------------------ + + def _iter_submodule_groups(self) -> Iterator[Tuple[str, "ParameterGroup"]]: + """Yield ``(dotted path, Parameter Group)`` for every Parameter + Group below this Parameter Manager, at any depth: never the root + itself, and never the reserved Globals submodule ``_globals`` or + anything inside it (D12).""" + + def walk( + group: "ParameterGroup", prefix: str + ) -> Iterator[Tuple[str, "ParameterGroup"]]: + for name, sm in group.submodules.items(): + assert isinstance(sm, ParameterGroup) + if name == "_globals": + continue + path = f"{prefix}{name}" + yield path, sm + yield from walk(sm, f"{path}.") + + yield from walk(self, "") + + @staticmethod + def _carries_effective_set( + group: "ParameterGroup", effective: Dict[str, Dict[str, str]] + ) -> bool: + """Whether the Parameter Group ``group`` carries every path of the + effective set ``effective`` with the unit the Type declares for it + (D12): a match requires existence **and** unit; values are + irrelevant.""" + for path, spec in effective.items(): + try: + param = group._get_param(path) + except ValueError: + return False + if getattr(param, "unit", None) != spec["unit"]: + return False + return True + + def _instances_of_effective( + self, effective: Dict[str, Dict[str, str]] + ) -> List[str]: + """Paths (relative to this Parameter Manager) of every Parameter + Group in the tree that is an Instance for the effective set + ``effective``: every submodule at any depth that carries the whole + set with the declared units (D12).""" + return [ + path + for path, group in self._iter_submodule_groups() + if self._carries_effective_set(group, effective) + ] + + def instances_of(self, type_name: str) -> List[str]: + """Paths (relative to this Parameter Manager) of every Instance of + the Type ``type_name`` (D12): every Parameter Group at any depth + that carries every path of the Type's effective set with the unit + the Type declares; values are irrelevant. The root is never an + Instance, the Globals submodule ``_globals`` and everything under + it are excluded, and an empty Type has no Instances (ADR-0001). + Matching is computed on demand; this query changes no state. + + Raises ``ValueError`` naming the name when no such Type exists, + and like :meth:`_effective_parameters` when its Nested Types cycle + or its effective set contains a path twice. + + :param type_name: Name of the Type. + :return: Paths of the Instances, in tree order. + """ + effective = self._effective_parameters(type_name) + if not effective: + return [] + return self._instances_of_effective(effective) + + def types_of(self, path: str) -> List[str]: + """Names of the Types claiming the parameter at ``path`` (a dotted + path relative to this Parameter Manager), innermost first (D16): + the Type whose Instance is the deepest submodule above the + parameter wins, then the one with the largest effective set, with + any remaining tie broken by Type name. A Type claims the parameter + when an Instance of it above the parameter — some submodule the + parameter lives under — carries the parameter's path relative to + that Instance in its effective set. Parameters under the Globals + submodule ``_globals`` are claimed by nothing, since matching + excludes ``_globals`` (D12). Matching is computed on demand; this + query changes no state. + + Raises ``ValueError`` naming the path when no parameter exists + there. + + :param path: Path of the parameter. + :return: Claiming Type names, innermost first. + """ + self._resolve_param(path) + # one claim per Type: when a Type claims the parameter through + # more than one Instance, its innermost Instance orders it + claims: Dict[str, Tuple[int, int]] = {} + for definition in self._types.values(): + effective = self._effective_parameters(definition.name) + if not effective: + continue + for submodule_path in self._instances_of_effective(effective): + prefix = f"{submodule_path}." + if not path.startswith(prefix): + continue + if path[len(prefix) :] not in effective: + continue + depth = len(submodule_path) + size = len(effective) + known = claims.get(definition.name) + if known is None or depth > known[0]: + claims[definition.name] = (depth, size) + return sorted( + claims, + key=lambda name: (-claims[name][0], -claims[name][1], name), + ) + + # ------------------------------------------------------------------ + # Type edits with Instance side effects (plan decisions D11, D13, D16) + # + # Every edit validates all its preconditions first and raises before + # touching anything: on an error the Type registry and the parameter + # tree are exactly as they were. The side effects target the Instances + # that exist before the edit — they are computed while the registry + # still holds the old shape, because after the edit no submodule + # matches until it carries what is new — together with the Instances + # of every Type whose effective parameter set contains the edited + # Type through nesting. The affected parameter paths are collected + # de-duplicated and each missing one is created once, through the + # ordinary ``add_parameter`` path, as a ``ManagedParameter`` with the + # entry's default value and unit. A parameter that already exists at + # a target path is left alone: the submodule it lives in simply stops + # being an Instance when its unit differs (D1). + # + # The Broadcasts go out only after the whole edit succeeded (D22): + # one ``parameter-creation`` per created parameter, in creation + # order, then the Type Locks applied to the submodules the edit + # turned into new Instances (D17), each emitting one + # ``pm-lock-update``, then one ``pm-type-update`` per affected Type — + # the edited Type first, then every Type nesting it, whose effective + # parameter set the edit changed too. The affected Types are the keys + # of ``_nesting_prefixes``, which the side-effect computation already + # walks. A refused edit emits nothing. + # ------------------------------------------------------------------ + + def _nesting_prefixes(self, type_name: str) -> Dict[str, List[str]]: + """Map every Type whose effective parameter set contains the + entries of ``type_name`` through nesting to the dotted submodule + prefixes under which they sit in that set. ``type_name`` itself + maps to ``[""]``; a Type requiring it directly at ``readout`` maps + to ``["readout."]``, and so on transitively, with one prefix per + nesting chain (a Type nesting it at several submodules maps to + several). The walk follows the ``nested`` maps upwards, from the + nested Types to the Types requiring them, and stops at a Type + already on the current branch, so it terminates even on a + registry that holds a cycle (which the public API refuses).""" + prefixes: Dict[str, List[str]] = {type_name: [""]} + + def walk(name: str, prefix: str, branch: Tuple[str, ...]) -> None: + for parent_name, definition in self._types.items(): + for submodule, nested_name in definition.nested.items(): + if nested_name != name or parent_name in branch: + continue + extended = f"{submodule}.{prefix}" + known = prefixes.setdefault(parent_name, []) + if extended not in known: + known.append(extended) + walk(parent_name, extended, branch + (parent_name,)) + + walk(type_name, "", (type_name,)) + return prefixes + + def _check_creation_targets(self, targets: List[Tuple[str, str]]) -> None: + """Validate the parameters a Type edit or :meth:`add_instance` is + about to create, before anything is mutated. ``targets`` holds + ``(Instance path, relative target path)`` pairs. An intermediate + segment of a target may not be an existing parameter (a parameter + cannot have child parameters) and the final segment may not be an + existing Parameter Group (a Parameter Group cannot become a + parameter); a target whose final segment is an existing parameter + is fine — it is left alone. The Instance path itself is walked + first: a segment of it that is an existing parameter blocks every + target below it, while a Parameter Group missing along it is + created on the way, so nothing below it can clash (this is how + ``add_instance`` names Parameter Groups that do not exist yet). + One de-duplicated target path may also not be a strict + segment-prefix of another target of the same edit: creating the + shorter parameter would take the Parameter Group the longer one + needs, so the edit could never carry out its own pre-check. + Raises ``ValueError`` naming every offending full path.""" + offending: Dict[str, str] = {} + seen: set = set() + for instance_path, relative_target in targets: + full = f"{instance_path}.{relative_target}" + if full in seen: + continue + seen.add(full) + # walk the Instance path: every segment must be a Parameter + # Group, a missing one is created on the way, and an existing + # parameter blocks everything below it + group: ParameterGroup | None = self + blocked: str | None = None + walked: List[str] = [] + for segment in instance_path.split("."): + if not segment: + continue + assert group is not None # cleared only with an immediate break + if segment in group.parameters: + blocked = ".".join([*walked, segment]) + break + submodule = group.submodules.get(segment) + if submodule is None: + # missing Parameter Group on the Instance path: it is + # created on the way, so nothing below it can clash + group = None + break + walked.append(segment) + group = cast(ParameterGroup, submodule) + if blocked is not None: + offending[full] = ( + f"'{blocked}' is a parameter, and cannot have child parameters" + ) + continue + if group is None: + continue + segments = relative_target.split(".") + for index, segment in enumerate(segments): + last = index == len(segments) - 1 + if segment in group.parameters: + if not last: + blocked = f"{instance_path}.{'.'.join(segments[: index + 1])}" + offending[full] = ( + f"'{blocked}' is a parameter, and cannot have " + "child parameters" + ) + break + if last: + if segment in group.submodules: + offending[full] = f"'{full}' is already a Parameter Group" + break + submodule = group.submodules.get(segment) + if submodule is None: + # missing Parameter Group: it is created on the way, + # so nothing deeper along this target can clash + break + assert isinstance(submodule, ParameterGroup) + group = submodule + # the prefix check runs over the de-duplicated targets of this one + # edit, whatever Instance or nesting chain produced them + full_paths = sorted(seen) + for index, shorter in enumerate(full_paths): + for longer in full_paths[index + 1 :]: + if longer.startswith(f"{shorter}."): + blocked, blocker = longer, shorter + elif shorter.startswith(f"{longer}."): + blocked, blocker = shorter, longer + else: + continue + offending.setdefault( + blocked, + f"'{blocker}' is also created by this edit, and a " + "parameter cannot have child parameters", + ) + if offending: + details = "; ".join( + f"cannot create parameter '{path}': {reason}" + for path, reason in offending.items() + ) + raise ValueError(details) + + def _require_type_entry(self, type_name: str, path: str) -> _TypeEntry: + """The Type ``type_name``'s own entry at ``path``. Raises + ``ValueError`` naming the path — and the Type that defines it, + when the path only reaches the effective parameter set through a + Nested Type — when it is not an entry of the Type itself.""" + definition = self._require_type(type_name) + entry = definition.parameters.get(path) + if entry is not None: + return entry + expanded = self._expand_effective(type_name) + if path in expanded: + from_type = expanded[path][1] + raise ValueError( + f"parameter path '{path}' is not an entry of Type " + f"'{type_name}' itself: it is only in the effective set " + f"through the entry of Type '{from_type}'" + ) + raise ValueError( + f"parameter path '{path}' is not an entry of Type '{type_name}'" + ) + + def _instances_before_edit( + self, affected: Dict[str, List[str]] + ) -> Dict[str, List[str]]: + """The Instances of every Type in ``affected``, computed while the + registry still holds the shape the edit is about to change (D13): + after the edit no submodule matches until it carries what is new, + so the side effects must key off the Instances found now.""" + return {name: self.instances_of(name) for name in affected} + + def add_type_parameter( + self, type_name: str, path: str, default: Any = None, unit: str = "" + ) -> None: + """Add an entry to the Type ``type_name`` (D11) and create the + parameter at ``path`` — with the entry's default value and unit — + in every Instance of the Type that lacks it, and in every Instance + of a Type whose effective parameter set contains ``type_name`` + through nesting (D13). Parameters that already exist at a target + path are left alone, whatever their unit; the missing Parameter + Groups along a target path are created. + + Raises ``ValueError`` — leaving the registry and the parameter + tree untouched — naming every offending path when no such Type + exists, when ``path`` is empty or has an empty segment, when + ``path`` is already in the Type's effective parameter set (naming + the Type that defines it), when ``prefix + path`` is already in + the effective parameter set of a Type nesting this one — naming + every colliding path and the Type whose effective set holds it, + since the duplicated path would break every query on that Type — + or when a target path cannot be created because an + intermediate segment is an existing parameter, the final segment + is an existing Parameter Group, or another target of the same + edit is a strict segment-prefix of it. + + After the edit succeeds it emits one ``parameter-creation`` + Broadcast per parameter it created, in creation order, followed by + one ``pm-lock-update`` Broadcast per Type Lock it applied to a new + Instance and one ``pm-type-update`` Broadcast per affected Type — + the edited Type first, then every Type nesting it, whose effective + parameter set the new entry extends (D17, D22, ADR-0003); a failed + validation emits nothing. A new Instance — a submodule that is an + Instance of an affected Type after the edit but was not one before + — gets the existing Type Locks of that Type applied to its + parameters at the Type's locked effective entries, parameters the + edit kept included; skips are collected into one ``logger.warning`` + and the edit still succeeds. + + :param type_name: Name of the Type. + :param path: Relative parameter path of the entry. + :param default: Default value the created parameters start with. + :param unit: Unit of the entry and the created parameters. + """ + # validate-then-mutate: every check below runs before the registry + # or the tree is touched + definition = self._require_type(type_name) + if not path or any(segment == "" for segment in path.split(".")): + raise ValueError(f"'{path}' is not a valid parameter path for a Type entry") + expanded = self._expand_effective(type_name) + if path in expanded: + from_type = expanded[path][1] + raise ValueError( + f"parameter path '{path}' is already in the effective set " + f"of Type '{type_name}' (defined by Type '{from_type}')" + ) + affected = self._nesting_prefixes(type_name) + # the new path must not collide in the effective parameter set of + # a Type nesting this one either — the same check the Nested Type + # edit runs for its candidate sets (the edited Type's own case is + # handled by the check above); every colliding path and the Type + # whose effective set holds it are collected, so one error can + # name them all (rule 3) + collisions: List[str] = [] + for name in affected: + current = set(self._expand_effective(name)) + for prefix in affected[name]: + candidate = f"{prefix}{path}" + if candidate in current: + described = f"'{candidate}' (in the effective set of Type '{name}')" + if described not in collisions: + collisions.append(described) + if collisions: + raise ValueError( + f"cannot add '{path}' to Type '{type_name}': parameter " + f"path(s) {', '.join(collisions)} would appear more than " + "once" + ) + instances_before = self._instances_before_edit(affected) + targets = [ + (instance_path, f"{prefix}{path}") + for name, prefixes in affected.items() + for instance_path in instances_before[name] + for prefix in prefixes + ] + self._check_creation_targets(targets) + definition.parameters[path] = _TypeEntry(default=default, unit=unit) + created: set = set() + creations: List[Tuple[str, Any, str]] = [] + for instance_path, relative_target in targets: + full = f"{instance_path}.{relative_target}" + if full in created: + continue + created.add(full) + if not self.has_param(full): + self.add_parameter(full, initial_value=default, unit=unit) + creations.append((full, default, unit)) + # broadcasts after the whole edit succeeded (D22): one + # parameter-creation per created parameter in creation order, + # then the Type Locks of the new Instances (each emitting its + # pm-lock-update, D17), then one pm-type-update per affected Type + for created_path, initial_value, created_unit in creations: + self._broadcast_parameter_creation( + created_path, initial_value, created_unit + ) + self._apply_type_locks_to_new_instances(list(affected), instances_before) + for name in affected: + self._broadcast_type_update(name) + + def remove_type_parameter(self, type_name: str, path: str) -> None: + """Remove the entry at ``path`` from the Type ``type_name``'s own + entries (D13): the parameters of the Instances are untouched, and + the submodules that no longer carry the whole shape simply stop + being Instances (D1). + + Raises ``ValueError`` naming the path when no such Type exists, + when ``path`` is not an entry of the Type itself — naming the + Type that defines it, when the path only reaches the effective + parameter set through a Nested Type — and when it is in no + effective set at all. Nothing is removed then. Emits one + ``pm-type-update`` Broadcast per affected Type — the edited Type + first, then every Type nesting it, whose effective parameter set + loses the path — after the entry is removed (D22); a failed + validation emits nothing. + + :param type_name: Name of the Type. + :param path: Relative parameter path of the entry. + """ + self._require_type_entry(type_name, path) + affected = self._nesting_prefixes(type_name) + del self._types[type_name].parameters[path] + for name in affected: + self._broadcast_type_update(name) + + def set_type_parameter_default(self, type_name: str, path: str, value: Any) -> None: + """Set the default value of the Type ``type_name``'s own entry at + ``path`` (D13): the parameters the Instances already carry keep + their values, and only parameters created later start with the + new default. + + Raises ``ValueError`` naming the path under the same conditions + as :meth:`remove_type_parameter`; nothing is changed then. Emits + one ``pm-type-update`` Broadcast carrying the edited Type's fresh + blueprint after the default is set (D22); no Broadcast names a + nesting Type, since a Type's effective parameter set carries + units and defining Types, not defaults, so their blueprints are + unchanged. A failed validation emits nothing. + + :param type_name: Name of the Type. + :param path: Relative parameter path of the entry. + :param value: The entry's new default value. + """ + entry = self._require_type_entry(type_name, path) + entry.default = value + self._broadcast_type_update(type_name) + + def set_type_parameter_unit(self, type_name: str, path: str, unit: str) -> None: + """Set the unit of the Type ``type_name``'s own entry at ``path`` + and propagate it to that parameter in every Instance of the Type + and of every Type whose effective parameter set contains + ``type_name`` through nesting (D13); the target of a propagation + is one of the Instance's parameters by construction, and a + parameter already carrying the new unit is simply set again. + + Raises ``ValueError`` naming the path under the same conditions + as :meth:`remove_type_parameter`; nothing is changed then. Emits + one ``pm-type-update`` Broadcast per affected Type — the edited + Type first, then every Type nesting it, whose effective parameter + set carries the changed unit — after the unit is set and + propagated (D22); a failed validation emits nothing. + + :param type_name: Name of the Type. + :param path: Relative parameter path of the entry. + :param unit: The entry's and the Instances' new unit. + """ + entry = self._require_type_entry(type_name, path) + # the Instances exist before the edit; the propagation targets are + # among their parameters by construction + affected = self._nesting_prefixes(type_name) + instances_before = self._instances_before_edit(affected) + entry.unit = unit + propagated: set = set() + for name, prefixes in affected.items(): + for instance_path in instances_before[name]: + for prefix in prefixes: + full = f"{instance_path}.{prefix}{path}" + if full in propagated: + continue + propagated.add(full) + if self.has_param(full): + cast(Parameter, self.parameter(full)).unit = unit + for name in affected: + self._broadcast_type_update(name) + + def add_nested_type(self, type_name: str, submodule: str, nested_type: str) -> None: + """Require the Nested Type ``nested_type`` at the submodule + ``submodule`` of the Type ``type_name`` (D11), and write the + nested Type's effective parameter set under that submodule into + every Instance of ``type_name`` — and of every Type nesting it — + that lacks the parameters, with each entry's default value and + unit (D13). + + Raises ``ValueError`` — leaving the registry and the parameter + tree untouched — naming every offending name or path when a Type + does not exist, when ``submodule`` is empty, has an empty segment + or starts with the reserved Globals name ``_globals`` (D18), when + the submodule already requires a Nested Type, when the nesting + would close a cycle (``type_name == nested_type`` included), when + the resulting effective parameter set of ``type_name`` or of any + Type nesting it would contain a path twice, or when a target path + cannot be created because an intermediate segment is an existing + parameter, the final segment is an existing Parameter Group, or + another target of the same edit is a strict segment-prefix of it. + + After the edit succeeds it emits one ``parameter-creation`` + Broadcast per parameter it created, in creation order, followed by + one ``pm-lock-update`` Broadcast per Type Lock it applied to a new + Instance and one ``pm-type-update`` Broadcast per affected Type — + the edited Type first, then every Type nesting it, whose effective + parameter set the nested entries extend (D17, D22, ADR-0003); a + failed validation emits nothing. A new Instance — a submodule that + is an Instance after the edit but was not one before — gets the + existing Type Locks of its Type applied to its parameters at the + Type's locked effective entries, parameters the edit kept included; + the Nested Type's own chain joins the Types whose Type Locks are + applied, since nesting it completes the submodules at its position + into Instances of it; skips are collected into one + ``logger.warning`` and the edit still succeeds. + + :param type_name: Name of the outer Type. + :param submodule: Name of the submodule that requires the Nested + Type. + :param nested_type: Name of the Nested Type. + """ + # validate-then-mutate: every check below runs before the registry + # or the tree is touched + missing = [ + f"no Type named '{name}' exists" + for name in dict.fromkeys((type_name, nested_type)) + if name not in self._types + ] + if missing: + raise ValueError("; ".join(missing)) + definition = self._types[type_name] + if not submodule or any(segment == "" for segment in submodule.split(".")): + raise ValueError( + f"'{submodule}' is not a valid submodule name for a Nested Type" + ) + if submodule.split(".")[0] == "_globals": + raise ValueError( + f"'{submodule}' is not a valid submodule name for a " + "Nested Type: the Globals submodule name is reserved" + ) + if submodule in definition.nested: + raise ValueError( + f"submodule '{submodule}' of Type '{type_name}' already " + f"requires the Nested Type '{definition.nested[submodule]}'" + ) + # the cycle check runs on a copied registry holding the candidate + # definition, so a refusal leaves the real one untouched + candidate = _TypeDefinition( + name=definition.name, + parameters=dict(definition.parameters), + nested={**definition.nested, submodule: nested_type}, + ) + candidate_registry = dict(self._types) + candidate_registry[type_name] = candidate + cycle = self._nested_cycle(candidate, types=candidate_registry) + if cycle is not None: + raise ValueError( + f"cannot nest Type '{nested_type}' at submodule " + f"'{submodule}' of Type '{type_name}': cycle in nested " + f"Types: {' -> '.join(cycle)}" + ) + # the resulting effective parameter set of the edited Type and of + # every Type nesting it must not contain a path twice; computed + # against the current registry, which the mutation below follows + affected = self._nesting_prefixes(type_name) + # the Nested Type's own chain joins the Types whose Type Locks are + # applied to new Instances (D17): nesting readout into qubit + # completes the submodules at the readout position into Instances + # of readout, whose Type Locks must be applied too; the creations + # and the pm-type-updates keep using ``affected`` only + lock_types = list( + dict.fromkeys([*affected, *self._nesting_prefixes(nested_type)]) + ) + instances_before = {name: self.instances_of(name) for name in lock_types} + nested_entries = self._effective_entries(nested_type) + collisions: List[str] = [] + for name in affected: + current = set(self._expand_effective(name)) + new_paths: List[str] = [] + for prefix in affected[name]: + for entry_path in nested_entries: + new_path = f"{prefix}{submodule}.{entry_path}" + if new_path in current or new_path in new_paths: + described = ( + f"'{new_path}' (in the effective set of Type '{name}')" + ) + if described not in collisions: + collisions.append(described) + else: + new_paths.append(new_path) + if collisions: + raise ValueError( + f"cannot nest Type '{nested_type}' at submodule " + f"'{submodule}' of Type '{type_name}': parameter path(s) " + f"{', '.join(collisions)} would appear more than once" + ) + targets = [ + (instance_path, f"{prefix}{submodule}.{entry_path}", entry) + for name, prefixes in affected.items() + for instance_path in instances_before[name] + for prefix in prefixes + for entry_path, entry in nested_entries.items() + ] + self._check_creation_targets( + [ + (instance_path, relative_target) + for instance_path, relative_target, _ in targets + ] + ) + definition.nested[submodule] = nested_type + created: set = set() + creations: List[Tuple[str, Any, str]] = [] + for instance_path, relative_target, entry in targets: + full = f"{instance_path}.{relative_target}" + if full in created: + continue + created.add(full) + if not self.has_param(full): + self.add_parameter(full, initial_value=entry.default, unit=entry.unit) + creations.append((full, entry.default, entry.unit)) + # broadcasts after the whole edit succeeded (D22): one + # parameter-creation per created parameter in creation order, + # then the Type Locks of the new Instances (each emitting its + # pm-lock-update, D17), then one pm-type-update per affected Type, + # the edited Type first + for created_path, initial_value, created_unit in creations: + self._broadcast_parameter_creation( + created_path, initial_value, created_unit + ) + self._apply_type_locks_to_new_instances(lock_types, instances_before) + for name in affected: + self._broadcast_type_update(name) + + def remove_nested_type(self, type_name: str, submodule: str) -> None: + """Remove the Nested Type required at the submodule ``submodule`` + of the Type ``type_name`` (D13): the parameters of the Instances + are untouched, and the submodules that no longer carry the whole + shape simply stop being Instances (D1). + + Raises ``ValueError`` naming the Type and the submodule when no + such Type exists or the submodule requires no Nested Type; + nothing is removed then. Emits one ``pm-type-update`` Broadcast + per affected Type — the edited Type first, then every Type nesting + it, whose effective parameter set loses the nested paths — after + the Nested Type is removed (D22); a failed validation emits + nothing. + + :param type_name: Name of the Type. + :param submodule: Name of the submodule that requires the Nested + Type. + """ + definition = self._require_type(type_name) + if submodule not in definition.nested: + raise ValueError( + f"submodule '{submodule}' of Type '{type_name}' has no Nested Type" + ) + affected = self._nesting_prefixes(type_name) + del definition.nested[submodule] + for name in affected: + self._broadcast_type_update(name) + + # ------------------------------------------------------------------ + # Instances (plan decision D14) + # + # ``add_instance`` writes a Type's effective parameter set into one + # named Parameter Group, creating the Parameter Groups on the way. + # It validates everything first: the Type, the name, a unit conflict + # on any existing parameter, and the creation targets through + # ``_check_creation_targets``; on an error nothing is created. An + # empty Type creates nothing and has no Instances (D12). + # + # After the whole call succeeded it emits one ``parameter-creation`` + # Broadcast per parameter it created, in creation order (D22, + # ADR-0003), then applies the existing Type Locks of the Type and of + # every Type nesting it to the submodules that are Instances only + # after the call (D17) — each applied Lock emitting its + # ``pm-lock-update``; it edits no Type, so it emits no + # ``pm-type-update``. A refused call emits nothing. + # ------------------------------------------------------------------ + + def add_instance(self, type_name: str, name: str) -> None: + """Create the Instance ``name`` of the Type ``type_name`` (D14): + every effective parameter path of the Type that is missing under + ``name`` — together with the Parameter Groups on the way — is + created with the entry's default value and unit through the + ordinary :meth:`add_parameter` path, and every parameter that + exists at a target path already is kept untouched, with its own + value and unit. ``name`` is a dotted submodule path relative to + this Parameter Manager (``"q01"`` or ``"q01.readout"``), so + nested Instances are allowed. After a successful call ``name`` + carries the whole effective set with the units the Type declares, + so it is an Instance in :meth:`instances_of` — unless the Type is + empty: an empty Type has no Instances (D12), creates nothing and + raises nothing, and the submodule is not created for it. + + Raises ``ValueError`` — creating nothing — naming every offending + path when no such Type exists, when ``name`` is empty or has an + empty segment, when ``name`` starts with the reserved Globals + name ``_globals`` (D18), when a parameter already exists at an + effective path with a unit different from the one the Type + declares (the unit-conflict scan runs over every effective path + before anything is created, D14), or when a target path cannot be + created because a segment of ``name`` or of the target is an + existing parameter, or the final segment of a target is an + existing Parameter Group. + + After the Instance is created it emits one ``parameter-creation`` + Broadcast per parameter it created, in creation order, followed by + one ``pm-lock-update`` Broadcast per Type Lock it applied to the + new Instance (D17, D22, ADR-0003); the call edits no Type, so it + emits no ``pm-type-update``. A failed validation emits nothing. + As a new Instance, ``name`` gets the existing Type Locks of this + Type and of every Type nesting it applied to its parameters at + their locked effective entries — a parameter the call kept (D14) + included, since a Lock changes neither its own value nor its unit. + A kept parameter that already carries a Lock on another Target, a + stored Target that no longer exists and a Lock that would close a + cycle are skipped, collected into one ``logger.warning``; the + creation itself still succeeds. Submodules that were Instances + before the call are untouched. + + :param type_name: Name of the Type. + :param name: Dotted submodule path of the Instance, relative to + this Parameter Manager. + """ + # validate-then-mutate: every check below runs before the tree is + # touched + self._require_type(type_name) + if not name or any(segment == "" for segment in name.split(".")): + raise ValueError(f"'{name}' is not a valid submodule path for an Instance") + if name.split(".")[0] == "_globals": + raise ValueError( + f"'{name}' is not a valid submodule path for an Instance: " + "the Globals submodule name is reserved" + ) + effective = self._effective_entries(type_name) + if not effective: + # an empty Type has no Instances (D12): there is nothing to + # create, and the submodule is not created for it + return + # the unit-conflict scan runs over every effective path before + # anything is created (D14); every conflict is collected, so one + # error can name them all (rule 3) + conflicts: List[str] = [] + for path, entry in effective.items(): + full = f"{name}.{path}" + if self.has_param(full): + existing_unit = getattr(self.parameter(full), "unit", None) + if existing_unit != entry.unit: + conflicts.append( + f"'{full}' carries unit '{existing_unit}', the " + f"Type declares '{entry.unit}'" + ) + if conflicts: + raise ValueError( + f"cannot add an Instance of Type '{type_name}' at " + f"'{name}': " + "; ".join(conflicts) + ) + self._check_creation_targets([(name, path) for path in effective]) + # the Instances of this Type and of every Type nesting it, before + # anything is created: the submodules that are Instances only after + # the call get the existing Type Locks applied (D17) + lock_types = list(self._nesting_prefixes(type_name)) + instances_before = {name_: self.instances_of(name_) for name_ in lock_types} + creations: List[Tuple[str, Any, str]] = [] + for path, entry in effective.items(): + full = f"{name}.{path}" + if not self.has_param(full): + self.add_parameter(full, initial_value=entry.default, unit=entry.unit) + creations.append((full, entry.default, entry.unit)) + # one parameter-creation per created parameter, in creation order, + # then the Type Locks of the new Instances (each emitting its + # pm-lock-update, D17); no pm-type-update: the call edits no Type + for created_path, initial_value, created_unit in creations: + self._broadcast_parameter_creation( + created_path, initial_value, created_unit + ) + self._apply_type_locks_to_new_instances(lock_types, instances_before) + + # ------------------------------------------------------------------ + # Globals (plan decision D18) + # + # The reserved Globals submodule ``_globals`` holds the default + # Targets of Type Locks. It is created on demand and is never an + # Instance; matching excludes it and everything under it (D12, the + # walk in ``_iter_submodule_groups``), and ``add_parameter`` refuses + # its name. The internal helper ``_ensure_global_target`` creates + # ``_globals..`` through the internal creation path — + # the same one the public ``add_parameter`` ends in. A Globals + # parameter is otherwise ordinary (D18): it can be set and read, it + # may itself carry a Lock and be a Lock Target, and it is saved with + # the profile. Removing one is allowed; :meth:`remove_parameter` then + # drops the Locks pointing at it and clears the Type Lock it was the + # stored Target of (D18). + # ------------------------------------------------------------------ + + def _ensure_global_target(self, type_name: str, path: str) -> str: + """Create the Globals parameter ``_globals..`` + for the Type ``type_name``'s own entry at ``path`` — on demand, + with the entry's default value and unit — and return its dotted + path relative to this Parameter Manager (D17, D18). + + This is the internal helper :meth:`lock_type_parameter` builds the + default Target of a Type Lock declaration with. + It bypasses the public :meth:`add_parameter` refusal of the + Globals name through the internal creation path + (:meth:`_create_managed_parameter`, the + ``_get_parent(..., create_parent=True)`` + + ``_add_own_parameter`` pair), creating the parameter as a + :class:`ManagedParameter` whose ``path`` is the full dotted form + with the instrument name, like :meth:`add_parameter` does. A + parameter that exists at the target path already is kept + untouched — its own value is not changed and nothing is emitted + (created on demand, D18). + + Raises ``ValueError`` — before anything is touched — naming the + offending name or path when no such Type exists, when ``path`` is + not an entry of the Type itself (naming the Type that defines it + when the path only reaches the effective parameter set through a + Nested Type; own entries only, like + :meth:`set_type_parameter_default`), when the parameter exists + with a unit different from the entry's unit (naming the path and + both units; unit conflicts are refused like D14), and — through + the same :meth:`_check_creation_targets` validation the Type + edits and :meth:`add_instance` run — when a segment of + ``_globals..`` on the way is an existing + parameter rather than a Parameter Group, or when the target path + is an existing Parameter Group. + + When it creates the parameter it emits exactly one + ``parameter-creation`` Broadcast in the same shape the Type-edit + side-effect creations use (D22, ADR-0003); it edits no Type, so + it emits no ``pm-type-update``. + + :param type_name: Name of the Type. + :param path: Relative parameter path of the Type's own entry. + :return: The path ``"_globals.."``. + """ + # validate-then-mutate: every check below runs before the tree is + # touched (rule 3) + entry = self._require_type_entry(type_name, path) + global_path = f"_globals.{type_name}.{path}" + if self.has_param(global_path): + existing_unit = getattr(self.parameter(global_path), "unit", None) + if existing_unit != entry.unit: + raise ValueError( + f"cannot create the Globals parameter '{global_path}': " + f"it exists already with unit '{existing_unit}', the " + f"entry of Type '{type_name}' declares '{entry.unit}'" + ) + # created on demand: an existing parameter is kept untouched, + # with its own value, and emits nothing (D18) + return global_path + # a parameter on the way blocks the creation, and the target path + # may not be an existing Parameter Group: the blocked-target + # validation is the one ``_check_creation_targets`` already owns + # for the Type edits and ``add_instance``. The single target here + # is ``.`` under the reserved Globals submodule — + # which is never an Instance (D18); the ``(Instance path, + # relative target)`` pair the helper takes is reused only for its + # walk, and missing Parameter Groups are created on the way + self._check_creation_targets([("_globals", f"{type_name}.{path}")]) + self._create_managed_parameter(global_path, entry.default, entry.unit) + self._broadcast_parameter_creation(global_path, entry.default, entry.unit) + return global_path + + # ------------------------------------------------------------------ + # Type Locks (plan decision D17, task 3.2) + # + # A Type Lock is a rule on a Type entry naming a Target: declaring it + # with ``lock_type_parameter`` stores the Target on the entry (the + # default Target is the Globals parameter ``_globals..``, + # created on demand through ``_ensure_global_target``) and puts an + # ordinary, locked Lock on the entry's parameter in every current + # Instance. Instance parameters that carry a Lock on another Target + # are skipped with a warning and returned; ``unlock_type_parameter`` + # removes only the rule, and the Locks it created stay until they are + # removed individually. Every new Instance — created by + # ``add_instance``, or completed by ``add_type_parameter`` / + # ``add_nested_type`` — gets the existing Type Locks of its Type at + # creation, pre-existing parameters included. The Locks themselves are + # applied through the ordinary Lock API, so each state change emits + # exactly one ``pm-lock-update`` (D10); the declaration and the removal + # each emit one ``pm-type-update`` for the edited Type (D22). + # ------------------------------------------------------------------ + + def _classify_lock_application( + self, param_path: str, target_full: str + ) -> Tuple[str, "str | None"]: + """Classify what applying the Target ``target_full`` (the full + dotted form) to the parameter at ``param_path`` would do: + ``"lock"`` (no Lock present), ``"relock"`` (an unlocked Lock + remembering the same Target), ``"none"`` (already locked to the + same Target) or ``"skip"`` together with the Target the + parameter's Lock points at — a Lock on another Target is left + alone (D17).""" + param = self.parameter(param_path) + lock = getattr(param, "lock", None) + if lock is None: + return "lock", None + if lock.target == target_full: + return ("none" if lock.locked else "relock"), None + return "skip", lock.target + + def lock_type_parameter( + self, type_name: str, path: str, target: str | None = None + ) -> List[str]: + """Declare the Type Lock of the entry ``path`` of the Type + ``type_name`` (D17): the Target is stored on the entry — the + parameter at ``target`` when given, the Globals parameter + ``_globals..`` otherwise — and an ordinary, + locked Lock on that Target is put on the entry's parameter in + every current Instance of the Type. The default Globals Target is + created on demand with the entry's default value and unit, through + :meth:`_ensure_global_target`. + + Instance parameters that already carry a Lock on another Target + are skipped: they are left untouched, named together with their + Target in one ``logger.warning``, and returned as a list of dotted + paths relative to this Parameter Manager (empty when nothing was + skipped). A parameter already locked to the same Target stays as + it is; an unlocked Lock remembering the same Target is locked + again. Declaring the Type Lock again therefore re-applies it to + everyone ("lock all"); declaring it with a different Target stores + the new one, and the Followers still locked to the old Target + count as skipped. + + Raises ``ValueError`` — before anything is touched — naming every + offending path when no such Type exists, when ``path`` is not an + entry of the Type itself (naming the Type that defines it when the + path only reaches the effective parameter set through a Nested + Type; own entries only, like + :meth:`set_type_parameter_default`), when an explicit ``target`` + does not exist as a parameter of this Parameter Manager, and when + locking one of the Instance parameters would be a self-lock, close + a cycle (walking Targets regardless of locked/unlocked state, D7) + or hit a parameter that cannot carry a Lock. + + On success it emits the Globals parameter's ``parameter-creation`` + (when created), then one ``pm-lock-update`` per Lock it created or + relocked — nothing for skipped or already-locked parameters — and + finally one ``pm-type-update`` for the edited Type (D10, D22); the + Types nesting it are not named, since their effective parameter + set carries units and defining Types, not Targets. A failed + validation emits nothing. + + :param type_name: Name of the Type. + :param path: Relative parameter path of the Type's own entry. + :param target: Path of the Target, relative to this Parameter + Manager; the default Globals Target when ``None``. + :return: The skipped Instance parameter paths, in tree order. + """ + # validate-then-mutate: every check below runs before the entry or + # any Lock is touched (rule 3) + entry = self._require_type_entry(type_name, path) + if target is not None: + # the explicit Target must be a parameter of this Parameter + # Manager, resolved like lock() resolves it (D8) + self._resolve_param(target) + target_relative = target + else: + target_relative = f"_globals.{type_name}.{path}" + target_full = self._full_path(target_relative) + # classify every current Instance's parameter at the entry path; an + # Instance always carries the parameter (D12) + applications: List[Tuple[str, str]] = [] + skipped: List[Tuple[str, str]] = [] + offenders: List[str] = [] + for instance_path in self.instances_of(type_name): + param_path = f"{instance_path}.{path}" + action, locked_to = self._classify_lock_application(param_path, target_full) + if action == "skip": + assert locked_to is not None # "skip" always names the Target + skipped.append((param_path, locked_to)) + continue + if action == "none": + continue + follower_full = self._full_path(param_path) + if not isinstance(self.parameter(param_path), ManagedParameter): + offenders.append(f"{follower_full} cannot carry a Lock") + continue + try: + self._check_lock_allowed(follower_full, target_full) + except ValueError as exc: + offenders.append(str(exc)) + continue + applications.append((param_path, action)) + if offenders: + raise ValueError( + f"cannot lock the Instance parameters of Type '{type_name}' " + f"entry '{path}' to {target_full}: " + "; ".join(offenders) + ) + # the default Target is the Globals parameter, created on demand; + # its parameter-creation Broadcast goes out before the Lock updates + if target is None: + self._ensure_global_target(type_name, path) + entry.target = target_full + # apply the Locks in tree order; each state change emits exactly + # one pm-lock-update through lock()/relock() (D10) + for param_path, action in applications: + if action == "lock": + self.lock(param_path, target_relative) + else: + self.relock(param_path) + if skipped: + described = ", ".join( + f"'{param_path}' (locked to {locked_to})" + for param_path, locked_to in skipped + ) + logger.warning( + f"Type Lock of Type '{type_name}' entry '{path}' to " + f"{target_full}: skipped Instance parameter(s) {described}, " + "which carry a Lock on another Target" + ) + # one pm-type-update for the edited Type, after the Lock updates; + # the Types nesting it are not named — their effective parameter + # set carries units and defining Types, not Targets (D22) + self._broadcast_type_update(type_name) + return [param_path for param_path, _ in skipped] + + def unlock_type_parameter(self, type_name: str, path: str) -> None: + """Remove the Type Lock of the entry ``path`` of the Type + ``type_name`` (D17): only the rule goes — the entry's stored Target + is cleared and one ``pm-type-update`` Broadcast naming the Type + goes out — while the Locks the declaration put on the Instance + parameters stay until they are removed individually with + :meth:`remove_lock`. Instances that stop matching keep their + Locks either way. + + Raises ``ValueError`` naming the Type and the path when no such + Type exists or ``path`` is not an entry of the Type itself; nothing + is changed then. An entry that carries no Type Lock is a no-op + logged at INFO level that emits nothing (like :meth:`unlock` and + :meth:`relock`). + + :param type_name: Name of the Type. + :param path: Relative parameter path of the Type's own entry. + """ + entry = self._require_type_entry(type_name, path) + if entry.target is None: + logger.info( + f"the entry '{path}' of Type '{type_name}' carries no Type " + "Lock; nothing to do" + ) + return + entry.target = None + self._broadcast_type_update(type_name) + + def _apply_type_locks_to_new_instances( + self, lock_types: List[str], instances_before: Dict[str, List[str]] + ) -> None: + """Apply the existing Type Locks of the Types ``lock_types`` to the + submodules that an edit just turned into new Instances (D17): a + Parameter Group that is an Instance after the edit but was not one + before gets a locked Lock on the entry's Target for every entry of + the Type's effective parameter set that carries one — a parameter + the edit kept (D14) included, since a Lock changes neither its own + value nor its unit. + + Per parameter an unlocked Lock remembering the same Target is + locked again, an already locked one is left as it is, and a Lock + on another Target is skipped. A Target that no longer exists — + not reachable through the public API, since + :meth:`remove_parameter` clears the Type Lock whose stored Target + it deletes (D18), but possible in a registry inserted by hand — a + parameter that cannot carry a Lock and one whose Lock application + would be a self-lock or close a cycle are skipped too — including + a cycle that another application of the same batch creates, since + every application is validated again against the state the earlier + ones left when it runs. No Lock application raises: every + ``ValueError`` the Lock API raises for one parameter is recorded + as that parameter's skip reason. Every skip is collected into one + ``logger.warning`` naming the Type, the entry path, the skipped + parameter path and the reason; the edit itself still succeeds and + the calling method keeps returning ``None``. Submodules that were + Instances before the edit are untouched: only + :meth:`lock_type_parameter` re-applies a Type Lock to everyone. + + Each applied Lock emits exactly one ``pm-lock-update`` through + :meth:`lock` / :meth:`relock` (D10); the caller runs this after + the ``parameter-creation`` Broadcasts and before the + ``pm-type-update`` Broadcasts. + + :param lock_types: Names of the Types whose Type Locks are applied, + the edited Type and the Types nesting it (and, for + :meth:`add_nested_type`, the Nested Type and the Types nesting + it). + :param instances_before: The Instances of every Type in + ``lock_types``, computed before the edit (see + :meth:`_instances_before_edit`). + """ + applications: List[Tuple[str, str, str, str, bool]] = [] + skipped: List[Tuple[str, str, str, str]] = [] + seen: set = set() + for type_name in lock_types: + locked_targets = { + entry_path: entry.target + for entry_path, entry in self._effective_entries(type_name).items() + if entry.target is not None + } + if not locked_targets: + continue + for instance_path in self.instances_of(type_name): + if instance_path in instances_before.get(type_name, []): + # an Instance before the edit: only lock_type_parameter + # re-applies a Type Lock to everyone + continue + for entry_path, target in locked_targets.items(): + param_path = f"{instance_path}.{entry_path}" + if param_path in seen: + # the same defining entry reaches the parameter + # through several Types of the closure + continue + seen.add(param_path) + if self._param_by_full_path(target) is None: + skipped.append( + ( + type_name, + entry_path, + param_path, + f"the stored Target {target} does not exist", + ) + ) + continue + action, locked_to = self._classify_lock_application( + param_path, target + ) + if action == "skip": + skipped.append( + ( + type_name, + entry_path, + param_path, + f"it carries a Lock on another Target ({locked_to})", + ) + ) + continue + if action == "none": + continue + param = self.parameter(param_path) + follower_full = self._full_path(param_path) + if not isinstance(param, ManagedParameter): + skipped.append( + ( + type_name, + entry_path, + param_path, + f"{follower_full} cannot carry a Lock", + ) + ) + continue + try: + self._check_lock_allowed(follower_full, target) + except ValueError as exc: + skipped.append((type_name, entry_path, param_path, str(exc))) + continue + applications.append( + ( + type_name, + entry_path, + param_path, + target[len(self.name) + 1 :], + action == "relock", + ) + ) + for ( + type_name, + entry_path, + param_path, + target_relative, + is_relock, + ) in applications: + try: + if is_relock: + self.relock(param_path) + else: + self.lock(param_path, target_relative) + except ValueError as exc: + # the earlier applications of this batch changed the Lock + # state the up-front classification validated against: the + # Lock API refused this one, so it is skipped like any + # other application that cannot run — the edit itself + # still succeeds and no exception escapes + skipped.append((type_name, entry_path, param_path, str(exc))) + if skipped: + described = "; ".join( + f"'{param_path}' (entry '{entry_path}' of Type '{type_name}') {reason}" + for type_name, entry_path, param_path, reason in skipped + ) + logger.warning( + "skipped Instance parameter(s) while applying the Type " + f"Locks to new Instances: {described}" + ) + + @staticmethod + def createFromParamDict(paramDict: Dict[str, Any], name: str) -> "ParameterManager": + """Create a new ParameterManager instance from a paramDict. + + :param paramDict: The paramDict object. + :param name: Name of the instrument in the paramDict (each entry in the + paramDict starts with .[...]). + :returns: New ParameterManager instance. + """ + raise NotImplementedError + + @staticmethod + def cleanProfileName(name: str) -> str: + """ + When passed the full file name of a parameter_manager profile, return only the middle + string representing the profile's name. + """ + return name.replace("parameter_manager-", "").replace(".json", "") + + @staticmethod + def fullProfileName(name: str) -> str: + """ + Adds 'parameter_manager-' to the beginning of `name` and adds '.json' at the end. + """ + + if not name.startswith("parameter_manager-"): + name = "parameter_manager-" + name + if not name.endswith(".json"): + name += ".json" + return name + + @classmethod + def does_profile_exist(cls, profiles: List[str], target: str) -> bool: + found = False + for profile in profiles: + if target in profile: + found = True + break + return found + + def refresh_profiles(self) -> List[str]: + """ + Goes into the working directory and updates the list of profiles. + + :return: List of profiles in the working directory + """ + profiles = [] + for filename in os.listdir(self.workingDirectory): + if filename.startswith("parameter_manager") and filename.endswith(".json"): + profiles.append(filename) + + self.profiles = profiles + return profiles + + def remove_all_parameters(self) -> None: + """Remove all parameters from the instrument.""" + for param in self.list(): + self.remove_parameter(param, cleanup=False) + self.remove_empty_submodules() + + def _clear_all(self) -> None: + """Remove every Type from the Type registry, then all parameters + with their Locks: the clear step of :meth:`switch_to_profile` + (D20). + + The Types go first, straight out of the registry with no + parameter side effects (D13) — one ``pm-type-update`` Broadcast + with a ``None`` payload per removed Type, in registry order + (D22). Removing them first means the parameter removals below + find no Type Lock to clear, so no further ``pm-type-update`` is + emitted. :meth:`remove_all_parameters` then removes every + parameter — the Globals parameters included — dropping their + Locks (one ``pm-lock-update`` Broadcast with a ``None`` payload + per dropped Lock, D10) and the Parameter Groups left empty. + """ + removed_types = list(self._types) + for type_name in removed_types: + del self._types[type_name] + for type_name in removed_types: + self.broadcast( + ParameterBroadcastBluePrint( + name=f"{self.name}.{type_name}", + action=PM_TYPE_UPDATE, + value=None, + ) + ) + self.remove_all_parameters() + def fromFile( self, filePath: str | None = None, deleteMissing: bool = True, ) -> None: - """Load parameters from a parameter json file + """Load parameters, Types and Locks from a parameter json file (see :mod:`.serialize`). If the filepath starts with 'parameter_manager-' and ends with '.json', @@ -334,17 +2502,88 @@ def fromFile( def fromParamDict( self, paramDict: Dict[str, Any], deleteMissing: bool = True ) -> None: - """Load parameters from a parameter dictionary (see :mod:`.serialize`). + """Load parameters, Types and Locks from a parameter dictionary + (see :mod:`.serialize`). - :param paramDict: Parameter dictionary. - :param deleteMissing: If ``True``, delete parameters currently in the - ParameterManager that are not listed in the file. + A dictionary without a top-level ``version`` key is the legacy flat + parameter map and loads exactly as before: parameters only, and the + Types and Locks of this Parameter Manager are untouched by the + legacy reader. + + A version-2 profile document (the document :meth:`toParamDict` + writes, plan decision D19) is validated as a whole before anything + is loaded (plan decision D20). The schema is checked with + :func:`serialize.validateParameterManagerV2`; every remaining + problem is collected into one ``ValueError`` naming all of them + (rule 3): every file key must belong to this Parameter Manager + (the full-path form with the instrument name), every Type name + must be valid (non-empty, not the reserved Globals name + ``_globals``), every Nested Type must name a Type of the document, + the document's Types must form no Nested Type cycle and hold no + effective parameter path twice, every ``lock`` Target and every + non-null Type Lock Target must be a ``parameters`` key of the + document, and no ``lock`` may be a self-lock or close a cycle + among the document's Locks (walking Targets regardless of + locked/unlocked state, D7). A refused document changes nothing and + emits nothing. Any other ``version`` value raises ``ValueError`` + naming it. + + After the validation the document loads in order (D20): first the + Locks of every parameter it lists go away, so setting a stored own + value on a currently locked Follower cannot raise (D6); then the + parameters with the semantics the reader always had (an existing + one is set to its stored own value and unit, a missing one is + created — one under the Globals submodule through the internal + creation path :meth:`_create_managed_parameter`, since the public + :meth:`add_parameter` refuses the Globals name (D18) — and + ``deleteMissing=True`` removes the parameters the document does + not list, dropping their Locks and Type Locks exactly like + :meth:`remove_parameter` does); then the Types, written straight + into the registry as definitions — with ``deleteMissing=True``, + every Type the document does not define is removed — with **no** + Instance side effects (D20): no parameter is created for an + Instance and no Type Lock is applied on load, so a partial + Instance stays partial and an Instance's Locks come only from the + document's ``lock`` entries; then the Locks, each set directly to + the stored full-form Target and stored locked state (like + :meth:`lock` creates it after the cycle check, not through a + ``lock()``/``unlock()`` pair). A parameter the document lists + without a ``lock`` entry ends with no Lock; parameters it does not + list keep theirs when ``deleteMissing=False``. + + The Broadcasts go out once, after the whole load succeeded (D22, + D10): one ``pm-type-update`` per Type the document wrote, then one + with a ``None`` payload per Type removed, then one + ``pm-lock-update`` per Lock that ended different from before the + load (``None`` when it was removed), in tree order. The load + emits nothing for the parameter values it sets, and it re-emits + **no** ``parameter-creation``/``parameter-deletion`` Broadcasts + for the parameters it creates and removes: the Server's + literal-name detection does not see them either, so a GUI watching + this Parameter Manager must refresh its structure after a profile + load. + + :param paramDict: Parameter dictionary — a legacy flat map or a + version-2 profile document. + :param deleteMissing: If ``True``, delete parameters currently in + the ParameterManager that are not listed in the file, and + remove the Types the file does not define. """ + if "version" in paramDict: + version = paramDict["version"] + if version != 2: + raise ValueError( + f"unsupported Parameter Manager profile version: " + f"{version!r} (this reader reads version 2 and the " + "legacy flat map, which carries no version key)" + ) + self._load_v2_document(paramDict, deleteMissing) + return + + # legacy flat parameter map: exactly the behaviour before the + # version-2 profile document existed serialize.validateParamDict(paramDict) - if serialize.isSimpleFormat(paramDict): - simple = True - else: - simple = False + simple = serialize.isSimpleFormat(paramDict) currentParams = self.list() fileParams = [ @@ -368,6 +2607,12 @@ def fromParamDict( assert hasattr(param, "unit") param.unit = unit + elif pn.startswith("_globals."): + # the public add_parameter refuses the Globals name (D18); + # a saved Globals parameter round-trips through the + # internal creation path + self._create_managed_parameter(pn, val, unit) + else: self.add_parameter(pn, initial_value=val, unit=unit) @@ -375,13 +2620,373 @@ def fromParamDict( if pn not in fileParams and deleteMissing: self.remove_parameter(pn) + def _collect_v2_document_problems(self, document: Dict[str, Any]) -> List[str]: + """Collect every validation problem of a version-2 profile + document into one list of messages, so a refused document can be + named in full (rule 3, D20). The schema leg has already run when + this is called; nothing here changes any state.""" + problems: List[str] = [] + parameters = document["parameters"] + document_types = document["types"] + prefix = f"{self.name}." + + # every file key belongs to this Parameter Manager: the full + # dotted form with the instrument name, like the writer stores it + for key in parameters: + if not key.startswith(prefix): + problems.append( + f"parameter key '{key}' does not belong to this " + f"Parameter Manager ('{self.name}')" + ) + + # every Type name is valid: non-empty, not the reserved Globals + # submodule name (D18) + for type_name in document_types: + if type_name == "_globals": + problems.append( + f"'{type_name}' is not a valid Type name: " + "the Globals submodule name is reserved" + ) + elif not type_name: + problems.append("'' is not a valid Type name: it is empty") + + # the candidate registry built from the document, the way + # add_nested_type validates a candidate: the expansion helpers run + # on it without touching the real registry + candidates = { + type_name: _TypeDefinition( + name=type_name, + parameters={ + path: _TypeEntry( + default=entry["default"], + unit=entry["unit"], + target=entry["target"], + ) + for path, entry in spec["parameters"].items() + }, + nested=dict(spec["nested"]), + ) + for type_name, spec in document_types.items() + } + + # every Nested Type names a Type of the document + for type_name, definition in candidates.items(): + for nested_name in definition.nested.values(): + if nested_name not in candidates: + problems.append( + f"Type '{type_name}' nests '{nested_name}', which " + "is not among the document's Types" + ) + + def closure_complete(name: str) -> bool: + seen: set = set() + stack = [name] + while stack: + current = stack.pop() + if current in seen: + continue + seen.add(current) + candidate = candidates.get(current) + if candidate is None: + return False + stack.extend(candidate.nested.values()) + return True + + # no Nested Type cycle and no effective path twice, per the + # expansion helpers on the candidate registry; Types whose Nested + # Type closure the document does not define completely are skipped, + # the missing names are reported above + reported: set = set() + for type_name in candidates: + if not closure_complete(type_name): + continue + try: + self._expand_effective(type_name, types=candidates) + except ValueError as exc: + message = str(exc) + if message not in reported: + reported.add(message) + problems.append(message) + + # every Lock Target and every non-null Type Lock Target is a + # parameters key of the document (D20); every missing Target is + # named with everything that refers to it + missing_targets: Dict[str, List[str]] = {} + for key, entry in parameters.items(): + lock = entry.get("lock") + if lock is not None and lock["target"] not in parameters: + missing_targets.setdefault(lock["target"], []).append( + f"the Lock on Follower '{key}'" + ) + for type_name, spec in document_types.items(): + for path, entry in spec["parameters"].items(): + target = entry["target"] + if target is not None and target not in parameters: + missing_targets.setdefault(target, []).append( + f"the Type Lock on '{type_name}.{path}'" + ) + for target, referees in missing_targets.items(): + problems.append( + f"Target '{target}' is not a parameter of the document: " + f"referred to by {', '.join(referees)}" + ) + + # no self-lock and no cycle among the document's Locks: the walk + # follows each Target's Lock within the document regardless of + # locked/unlocked state (D7) + document_locks = { + key: entry["lock"] for key, entry in parameters.items() if "lock" in entry + } + for follower_full, lock in document_locks.items(): + target_full = lock["target"] + if target_full == follower_full: + problems.append(f"cannot lock {follower_full} to itself") + continue + chain = [target_full] + seen = {target_full} + current = target_full + while True: + next_lock = document_locks.get(current) + if next_lock is None: + break + nxt = next_lock["target"] + if nxt == follower_full or nxt in seen: + problems.append( + f"cannot lock {follower_full} to {target_full}: " + f"cycle in Lock targets: {' -> '.join(chain + [nxt])}" + ) + break + seen.add(nxt) + chain.append(nxt) + current = nxt + + return problems + + def _load_v2_document(self, document: Dict[str, Any], deleteMissing: bool) -> None: + """Validate a version-2 profile document as a whole and load it in + D20's order: the Locks of the listed parameters, the parameters, + the Types without Instance side effects, the Locks — and emit the + load's Broadcasts once, after it succeeded (D22, D10).""" + serialize.validateParameterManagerV2(document) + problems = self._collect_v2_document_problems(document) + if problems: + raise ValueError( + "invalid version-2 Parameter Manager profile document: " + + "; ".join(problems) + ) + + parameters = document["parameters"] + document_types = document["types"] + file_params = [key[len(self.name) + 1 :] for key in parameters] + file_param_set = set(file_params) + # the pre-load Lock state, for the diff the load reports at the end + previous_locks = self.list_locks() + + # The methods reused below announce their own state changes + # (remove_parameter drops Locks and clears Type Locks with one + # Broadcast each, D10/D22). The load reports the whole transition + # itself, once, after it succeeded, so the reused methods run with + # the sinks detached; the sinks are back before the load's own + # Broadcasts go out. + saved_sinks = self._broadcast_sinks + self._broadcast_sinks = [] + try: + # (a) the Locks of every parameter the document lists go + # first: setting a stored own value on a currently locked + # Follower would raise otherwise (D6); the Locks are + # re-created from the document in step (d) + for pn in file_params: + if self.has_param(pn): + param = self.parameter(pn) + if isinstance(param, ManagedParameter) and param.lock is not None: + param.lock = None + param._target = None + + # (b) the parameters, with the semantics the reader always had + current_params = self.list() + for pn in file_params: + entry = parameters[f"{self.name}.{pn}"] + val = entry["value"] + unit = entry.get("unit", "") + + if self.has_param(pn): + self.parameter(pn)(val) + if unit is not None: + param = self.parameter(pn) + assert hasattr(param, "unit") + param.unit = unit + + elif pn.startswith("_globals."): + # the public add_parameter refuses the Globals name + # (D18); a saved Globals parameter round-trips through + # the internal creation path + self._create_managed_parameter(pn, val, unit) + + else: + self.add_parameter(pn, initial_value=val, unit=unit) + + if deleteMissing: + for pn in current_params: + if pn not in file_param_set: + self.remove_parameter(pn) + + # (c) the Types, written straight into the registry with no + # Instance side effects (D20): no parameter is created for an + # Instance and no Type Lock is applied on load + written_types: List[str] = [] + removed_types: List[str] = [] + if deleteMissing: + for type_name in list(self._types): + if type_name not in document_types: + del self._types[type_name] + removed_types.append(type_name) + for type_name, spec in document_types.items(): + self._types[type_name] = _TypeDefinition( + name=type_name, + parameters={ + path: _TypeEntry( + default=entry["default"], + unit=entry["unit"], + target=entry["target"], + ) + for path, entry in spec["parameters"].items() + }, + nested=dict(spec["nested"]), + ) + written_types.append(type_name) + + # (d) the Locks, each set directly to the stored full-form + # Target and stored locked state — like lock() creates it + # after the cycle check, not through a lock()/unlock() pair + for pn in file_params: + lock_entry = parameters[f"{self.name}.{pn}"].get("lock") + if lock_entry is None: + continue + param = self.parameter(pn) + if not isinstance(param, ManagedParameter): + raise ValueError(f"{self._full_path(pn)} cannot carry a Lock") + follower_full = self._full_path(pn) + target_full = lock_entry["target"] + self._check_lock_allowed(follower_full, target_full) + target_param = self._param_by_full_path(target_full) + assert target_param is not None, ( + "the validated Target is not a parameter of this Parameter Manager" + ) + param._target = target_param + param.lock = PMLockBluePrint( + target=target_full, locked=lock_entry["locked"] + ) + finally: + self._broadcast_sinks = saved_sinks + + # the load's Broadcasts, after the whole load succeeded (D22, + # D10): one pm-type-update per Type written, one with a None + # payload per Type removed, then one pm-lock-update per Lock that + # ended different from before the load, in tree order. Values set + # during the load and the created and removed parameters emit + # nothing (see fromParamDict). + for type_name in written_types: + self._broadcast_type_update(type_name) + for type_name in removed_types: + self.broadcast( + ParameterBroadcastBluePrint( + name=f"{self.name}.{type_name}", + action=PM_TYPE_UPDATE, + value=None, + ) + ) + after_locks = self.list_locks() + tree_paths = [rel_path for rel_path, _ in self._iter_params()] + tree_set = set(tree_paths) + ordered_paths = [ + rel_path + for rel_path in tree_paths + if rel_path in previous_locks or rel_path in after_locks + ] + # a Follower whose parameter the load removed is no longer in the + # tree; its Lock still ended removed and is reported last + ordered_paths += [ + rel_path for rel_path in previous_locks if rel_path not in tree_set + ] + for rel_path in ordered_paths: + before = previous_locks.get(rel_path) + after = after_locks.get(rel_path) + if before != after: + self._broadcast_lock_update(rel_path, after) + def toParamDict( self, simpleFormat: bool = False, includeMeta: List[str] = ["unit"] ) -> Dict[str, Any]: + """Return the state of this Parameter Manager as a version-2 + profile document (plan decision D19): + ``{"version": 2, "parameters": {...}, "types": {...}}``. + + ``parameters`` maps every parameter's full dotted path (with the + instrument name, like the legacy flat files) to a dict holding the + parameter's **own** value — :meth:`ManagedParameter.own_value` + for the parameters that can carry a Lock, so a locked Follower + saves its own value and never the Target's (ADR-0002), the cached + snapshot value for any plain ``Parameter`` — the metadata + ``includeMeta`` selects (``unit`` by default; Globals parameters + are saved like any other, D18), and, only on a parameter that + carries a Lock in either state, a ``lock`` entry + ``{"target": , "locked": }``. + + ``types`` maps every Type of the Type registry to its own entries + as ``{path: {"default": ..., "unit": ..., "target": ...}}`` — the + stored full-form Target of the entry's Type Lock, or ``None`` — + and its Nested Types as ``{submodule: type}``; with no Types it is + ``{}``. + + The values are read from the qcodes snapshot with ``update=False`` + (the cache, never ``get``), like the legacy writer this replaces. + + ``simpleFormat`` is kept for signature compatibility but has no + effect: a version-2 document always stores the per-parameter + dict. ``includeMeta`` still selects the per-parameter metadata + besides ``value``. + + :param simpleFormat: Ignored (kept for signature compatibility). + :param includeMeta: List of parameter attributes to include + besides value. All keys occurring in snapshots are valid. + :return: The version-2 profile document. + """ params = serialize.toParamDict( - [self], simpleFormat=simpleFormat, includeMeta=includeMeta + [self], simpleFormat=False, includeMeta=includeMeta ) - return params + for rel_path, param in self._iter_params(): + full_path = self._full_path(rel_path) + if full_path not in params: + continue + if isinstance(param, ManagedParameter): + # the snapshot of a locked Follower reports the Target's + # value (ADR-0002); the profile stores the own value, so + # unlocking exposes it again + params[full_path]["value"] = param.own_value() + lock = getattr(param, "lock", None) + if lock is not None: + params[full_path]["lock"] = { + "target": lock.target, + "locked": lock.locked, + } + return { + "version": 2, + "parameters": params, + "types": { + type_name: { + "parameters": { + path: { + "default": entry.default, + "unit": entry.unit, + "target": entry.target, + } + for path, entry in definition.parameters.items() + }, + "nested": dict(definition.nested), + } + for type_name, definition in self._types.items() + }, + } def toFile( self, @@ -389,6 +2994,8 @@ def toFile( name: str | None = None, ) -> None: """Save parameters from the instrument into a json file. + The file holds the version-2 profile document :meth:`toParamDict` + returns (plan decision D19), dumped with ``indent=2, sort_keys=True``. If the file being saved is a profile file (starts with 'parameter_manager-' and ends with '.json'), the selectedProfile is changed to the filename. @@ -428,13 +3035,19 @@ def list_profiles(self) -> List[str]: def switch_to_profile(self, profile: str) -> None: """ - Switches the server to the passed profile. + Switches to the passed profile (D20): the current profile is + saved first (the version-2 profile document :meth:`toFile` + writes), then every parameter, Type and Lock is cleared + (:meth:`_clear_all`), then the new profile is loaded + (:meth:`fromFile`, a legacy flat map or a version-2 document). + Raises ``ValueError`` — saving, clearing and loading nothing — + when the profile does not exist. """ if not self.does_profile_exist(self.profiles, profile): raise ValueError(f"Profile {profile} does not exist") self.toFile(str(self.workingDirectory), self.selectedProfile) - self.remove_all_parameters() + self._clear_all() self.fromFile( str(self.workingDirectory.joinpath(self.fullProfileName(profile))) ) diff --git a/src/instrumentserver/resource.py b/src/instrumentserver/resource.py index bdf9e78..775214d 100644 --- a/src/instrumentserver/resource.py +++ b/src/instrumentserver/resource.py @@ -258,6 +258,288 @@ \x31\x3d\x22\x31\x32\x22\x20\x79\x31\x3d\x22\x32\x22\x20\x78\x32\ \x3d\x22\x31\x32\x22\x20\x79\x32\x3d\x22\x31\x35\x22\x3e\x3c\x2f\ \x6c\x69\x6e\x65\x3e\x3c\x2f\x73\x76\x67\x3e\ +\x00\x00\x08\xaa\ +\x00\ +\x00\x1f\x81\x78\x9c\xed\x59\xc9\x92\xab\xb8\x12\xfd\x15\x87\xb7\ +\x15\xdd\x06\x0c\xe5\xf2\x8d\x5b\x37\x82\xd9\x60\x06\x83\x99\xa4\ +\x1d\x83\x0b\x0c\x08\x3c\x60\x33\x7c\xfd\x13\x76\x95\xbb\xfa\x76\ +\xbf\xc5\x5b\x3f\xe7\x06\x48\x49\xa9\xcc\x93\x99\x47\x44\xe8\xe7\ +\xf9\x9a\x4e\x3a\x54\x56\xe7\xf7\x69\xd6\x34\x87\x1f\xb3\x59\xdb\ +\xb6\x7f\xb6\xf3\x3f\xeb\x53\x3a\xa3\x08\x82\x98\xe1\x19\xd3\x49\ +\xbb\x4f\x9a\xec\x7d\x4a\xd1\xd3\x49\xb6\xdb\xa7\x59\x73\x7f\xbf\ +\xee\x77\x2d\x57\x77\xef\x53\x62\x42\x4c\x28\x7a\x32\xea\x3e\xf6\ +\x65\xf9\x3e\xad\xea\x6a\x37\x9d\x9c\x9b\x53\x5d\xec\xde\xa7\xf1\ +\xe5\x74\xda\x55\x0d\x5f\x97\xf5\xe9\x4b\xfb\xc7\x97\xcd\x87\xa2\ +\xdc\x57\xbb\x38\x3c\xbc\x4f\x4f\xf5\xa5\x4a\xfe\xa6\xce\xeb\x7d\ +\xf5\xd0\xdf\xfc\xfd\x11\x53\x87\xf0\xe1\xf4\xf8\x71\x73\x19\x85\ +\xd5\xfe\x63\x77\x6e\xa6\xbf\x7e\xa2\x5d\x13\x26\x61\x13\xfe\xfa\ +\x39\x8e\xfe\xf8\x1a\xf9\xc5\xb2\xac\x9f\xa2\x03\x19\xf9\x0a\x7e\ +\x65\x77\x61\xe5\x35\x50\xd6\xfb\x58\x16\x59\xd1\x62\xad\x14\x2b\ +\x8f\x29\x4b\xa3\xb9\x28\x80\x41\x6d\x81\x85\x15\x1c\x8c\xc7\x79\ +\x60\x1c\x14\x93\x63\xe2\x93\x05\x1e\x6b\xf0\x98\xcd\x72\x3c\x56\ +\xf2\x47\x56\xd8\x47\xb1\x65\x90\x31\xa2\x5f\x6f\xeb\x9c\x9a\x01\ +\x03\xa0\x0d\x0a\x16\x7a\x4f\x66\x86\x0f\x48\xcd\xb1\x3a\x7d\xb0\ +\x1a\xd3\xd1\x7b\x7d\x4b\x74\xba\x90\x76\x50\x16\x3b\x23\x37\x0a\ +\xc3\x89\x47\x7f\x84\x92\x7a\xf8\x76\xf8\xf2\x0d\x04\xfa\xbf\xfb\ +\xb6\x65\xb2\x78\x6e\x94\x71\x65\x1f\x22\x8a\x19\xc6\x45\xac\xd6\ +\x8d\xfe\xed\xc7\x57\x5b\x1e\x6d\xd9\x39\x40\xcb\x9e\xe5\x70\x7c\ +\xa3\xd9\x75\xcd\x9a\x6a\xd3\x89\xd4\x2d\xe6\x4b\xe8\x33\x55\x8c\ +\xbc\x22\xf4\xbd\x4b\xc2\x33\x94\xde\xde\xac\x00\x6c\x7f\x08\x15\ +\xc0\x64\x33\xe8\x78\xc0\x83\x1e\xc9\xd6\x73\x0d\xa8\x43\x34\xfa\ +\xc9\xb5\x80\x52\xaf\xf1\xd1\x38\x60\xff\x5e\x21\xb6\x1f\xf9\x12\ +\x11\xf9\x65\x03\xfc\xa4\xd4\xe6\x38\xea\xbe\xa1\x23\xbf\x3b\x62\ +\xfb\x43\x22\x4b\x17\x40\x79\xaa\xbd\x4a\xcf\x3b\x99\x6c\x4d\x54\ +\x1e\xa0\x70\x28\x61\x5e\x22\xbc\x4f\x06\xf7\x04\xad\x3b\x0a\x81\ +\xf1\xa1\x4d\x59\x69\x00\x82\xb9\xd1\x13\xa4\x2e\x83\x56\x77\xdc\ +\xce\x10\xbc\xc2\x18\xb2\x33\xf6\xf3\x0c\x82\x7b\xac\xa1\x5c\xb6\ +\xe1\x8a\xcb\xb0\xee\x92\x88\x4b\x34\x3a\x45\x3f\xf2\xca\x71\x77\ +\xec\x8c\x7d\x34\x57\x7e\xc7\xce\xf9\xc2\x0e\xcc\xef\xb6\xb4\x0a\ +\xf4\xb7\xa8\xb9\x2c\xd7\x2b\xa3\x2e\xfa\x83\xcc\x9c\x3c\xee\xe5\ +\x50\x19\x9d\x31\x18\xb6\x27\x6d\xb4\x71\x1c\x96\x37\x2c\x6b\x94\ +\x3c\xd6\xa6\x47\x15\x01\xdf\x20\x42\x7f\x79\x09\x3f\x31\x8d\xe6\ +\x5c\x19\x8d\x98\x56\xa3\x7f\x52\x03\x03\x1b\xe7\xc8\xcc\x42\xaa\ +\xbc\xc0\xb9\x5a\x42\xb9\x2c\xa3\xca\x1e\xd2\x50\xcd\x93\x40\x3d\ +\xef\x78\x72\x80\x7e\x87\x94\x5b\xee\x55\xb4\xf1\xef\x76\x40\x60\ +\x0c\x30\x50\x6f\xb6\xe3\x7e\x89\x7d\xe3\x32\x0d\x7d\xb7\x61\x5d\ +\x12\x5c\x3b\xa1\x2c\x0d\xa1\x94\xa6\x4a\x02\x97\xbd\x5a\xb8\xaf\ +\xb3\xa2\xda\x5f\x6c\xa9\x36\xce\x89\xb3\x58\x53\x97\x99\xaf\xac\ +\x05\x21\xb6\x20\x1f\xb4\x26\x69\xcf\x13\xbe\x80\x48\xca\x13\xb9\ +\xbc\x46\x55\x9a\x00\x6a\xd9\x68\x48\xba\x24\x72\xd6\x63\xdf\x0f\ +\xa0\x67\xf2\x48\x96\x48\x28\xbb\x97\x78\xa5\x5e\x13\x54\x16\xd0\ +\xb7\x8f\xb1\x2c\xf5\xc0\x27\xcb\x44\xf6\xfa\xf8\x55\xa2\x57\x94\ +\x71\x8d\x30\x8e\x38\x96\x3a\x46\xcb\x36\xf4\x75\x1c\xbb\x7a\x80\ +\x38\x4e\xcd\xc7\x63\x08\xe2\xfa\xf0\xc6\xdc\x57\x89\xcf\x9c\x22\ +\xb4\x9c\x47\xa8\x29\x60\x60\xe4\x31\x2a\xdb\xfb\xfe\x19\xb2\xa8\ +\x2e\x4b\x7c\xbb\x54\x56\x1c\xde\xff\xb6\xa6\x50\x56\x76\x1d\x06\ +\x7a\x0a\x51\x79\x86\x5b\x2e\x4b\x78\x8e\x08\x65\x37\xc5\xf9\xee\ +\x12\xdf\x1b\xf0\xf7\x15\xee\xb9\x4c\x59\x79\x18\x23\x25\x05\x3e\ +\x53\x28\x32\x99\xed\xb6\x5c\x0d\x02\x58\x2a\xb2\xd1\x43\x5c\x93\ +\xd0\xb7\x52\x5c\x03\x69\xe4\x2f\x71\x8d\x63\xdb\xf8\x1b\xc7\x89\ +\xc7\xe1\x21\xc2\xf6\x70\xec\x38\x6e\x5c\x43\x2b\xec\x7b\x60\x5c\ +\x61\x65\xcf\x41\xa0\x96\x16\xae\xe3\xa8\xda\x66\x50\xc6\xf1\xf9\ +\xde\xc3\x47\x9c\xcf\x73\x24\x7e\x61\x67\xa8\x11\xc2\x20\x63\x3d\ +\xdc\xb8\x63\xfb\x2a\x7f\xf1\x04\x77\xe7\x89\xb1\x4f\xd2\xdf\x78\ +\xc2\x36\x3e\x73\x99\x65\x31\x95\x5e\xa0\x2c\x11\x37\x9e\x79\xf4\ +\x9d\x3d\xf4\xb3\x0f\x7f\x4d\x5f\x9c\xb7\x64\x41\x0d\x4a\x9d\x5f\ +\x25\x7d\x5c\xcf\xde\xfb\xce\xcb\x81\xdf\x55\x10\xd7\x2a\x70\x14\ +\xd2\x40\x46\x0b\x7c\xdb\x60\x7f\x17\xd9\xc6\x58\x18\xb5\xcf\xf3\ +\x4b\x31\xb2\x18\xfa\xbc\x89\x1a\x98\xea\x8b\x60\xa8\xae\x6f\x29\ +\xeb\x6a\x51\xf3\xb6\xa7\x08\x70\x14\x83\xba\x94\x0e\xbb\x43\x0b\ +\x4a\xd9\xbe\x8c\x39\x8e\xd0\xbd\x16\x47\x4c\x23\x54\x22\x9c\x33\ +\x22\x44\x1e\x0d\xa8\x8e\x8c\xa9\x31\x76\x93\xab\x91\x37\xf6\x76\ +\x9f\x70\x39\x01\x51\x87\xd7\x24\x44\xc8\x15\x3b\x6e\xdc\x3b\x67\ +\x1e\x3c\xa4\x52\x77\x1e\xd2\x31\x56\xbf\xf3\x10\xfb\xd9\x33\xd8\ +\x6e\x16\xfa\x04\xae\xe7\x1b\xf7\xf1\x02\x35\xf6\xee\xba\x04\x94\ +\x74\x86\x14\xc4\xfd\x2e\xf6\x86\x03\x0f\x78\xef\x2a\x42\x12\x81\ +\xfb\xa6\xdc\x89\xbf\xf5\xcd\xf6\xb3\x47\xe6\x5e\x1f\xe5\x87\xdb\ +\xbb\x99\x97\xb9\x91\xa7\x73\x88\xac\x41\xf3\x45\x12\xe6\x6e\x63\ +\x08\xe2\x60\xf0\x04\xa3\x0f\x4a\xa7\x39\x62\x6b\x0a\x52\x01\x1c\ +\xcc\x88\x94\x45\x1a\x5f\x7d\x56\x61\x6e\xa3\x98\x2c\x59\x79\xb8\ +\x8e\x0e\x07\x9c\x6b\x02\xd7\x58\x0e\xdd\x52\xdc\xf1\x1d\x1d\x05\ +\xec\x6b\xe8\x97\x85\x89\x74\xd2\x14\xbc\x3d\x70\xec\x42\x73\xd4\ +\x0c\x0c\x05\xb6\xcf\x92\x23\xe7\x43\xa4\xe0\x3d\x15\x12\xe4\x98\ +\xcf\x10\x66\xf9\xdc\xa5\x8d\xb9\x8a\x6b\xdf\xcb\x70\xcd\x15\x01\ +\x25\x0d\x31\xe5\xf5\xf7\x5a\x32\x85\x1a\x8d\xe7\x47\x47\x6b\x81\ +\x51\x46\x32\xc8\x6f\xb5\x84\xc0\xf2\xdf\xb8\x5e\xfb\x6f\xfc\x4d\ +\x7d\xe5\x9c\xcd\xc8\x6a\xae\x20\xdb\x39\xbd\x6c\x77\x6b\x46\xf4\ +\x53\x9c\xba\xcd\xf9\x14\x2e\x96\x20\xad\x75\xa3\xe3\x0a\xf5\x83\ +\x5d\x78\xab\x84\x58\x7f\xf1\xd0\xe1\x7f\xe0\xa1\xaf\x1e\xd0\x71\ +\xce\xd4\x07\x07\xa9\xb4\xf9\xca\xc7\x2b\xd1\x4e\x74\x57\x98\x15\ +\x62\x63\x5d\x0f\x4b\xb2\x77\x84\xb9\x60\xda\x4c\xc5\xb5\xf3\x5d\ +\x7c\x51\xe3\x6c\x6d\xa3\x3d\x18\xf3\xb4\x4a\x71\x4e\xbd\x33\xdc\ +\x1b\xb7\x7a\x81\x39\xf9\x65\xff\x1b\x36\xfa\xf5\x13\x83\x5b\x6c\ +\x1a\xb2\x31\x7e\xd2\x63\xcf\x82\x34\x4c\xd5\x60\x88\x37\xc2\xe7\ +\x8c\x98\xb7\x5d\xbf\xf5\xad\xab\xea\x4a\xb1\x03\xde\x78\xc5\xb8\ +\x9c\x12\x47\xd8\x9e\xca\xa2\x2e\x89\x7b\x9d\x91\x1f\x90\xf2\x2e\ +\x38\x26\x6c\x67\xd9\x07\x23\x2f\xa3\x97\xfc\xb3\xd7\xaf\x96\xcf\ +\x10\x21\xe6\xbd\x58\x2e\x73\x45\x1c\x79\x02\x73\xcc\x1c\x62\xec\ +\x8d\x11\xfb\x52\xdf\xd2\xad\x96\x73\xa7\x18\xf3\x3c\x20\xbf\xe9\ +\xf7\x34\xa1\xe5\xe3\x39\xc2\xd2\x0f\x1e\xe0\xb3\xaf\xff\x85\x3c\ +\x6e\xff\xf1\xbf\xf0\xcf\x5a\xbb\xfd\x6f\xb0\xca\xad\xcf\x9b\xb5\ +\xe8\x5b\xca\xb6\x4e\x45\x24\xf3\x12\x64\xd3\xba\xc5\x2c\x2f\xeb\ +\x8a\xc2\xe5\x21\x2b\xb0\xa9\x88\xb3\x6c\xb3\x4c\xc8\xae\x17\x67\ +\x85\x21\xb4\x57\x3a\x9d\xd5\xb2\x95\xba\x4b\x48\xba\x0e\x9e\xb1\ +\xe6\xd2\xf4\x98\x15\xb9\xb9\xb1\x2c\x81\x1d\x38\x55\xb7\xe3\x56\ +\xb2\x80\xe0\x59\xd6\x5a\x6c\x19\xee\x1b\x77\x9f\x15\x89\xe3\xad\ +\x41\xbc\xe8\x7c\x2b\xb3\xa4\x2b\xb2\x9d\x5e\xfe\x1d\x0b\xcc\xe9\ +\x95\x3d\x9e\x3b\xa9\x75\x3b\x83\x70\xcd\xc9\x65\x16\xad\xf4\xd4\ +\x45\xcb\x2b\xe6\x64\xc1\x72\xd8\x9d\xd4\x12\xbd\x91\xb3\xb4\x2e\ +\xc4\x9d\x29\x58\x03\xee\xdb\x70\xd4\x99\xc2\xa8\x03\x9d\xe9\xdc\ +\x75\xba\x68\x75\xd2\xc0\x7a\x5c\x6a\x78\x1c\x5b\x3b\x42\xf1\xfd\ +\x0c\x6a\x53\x57\x54\x05\x7d\x5b\xb4\x38\xf3\xa3\xbf\x82\xd8\x73\ +\xdf\xfd\x4d\x1f\xe7\xc6\x37\xbf\xdc\x1b\x3f\xe0\x7c\x0e\x1c\xd4\ +\x39\x5d\xe6\xfa\xa3\xbc\xd5\xe9\x25\xc6\x4a\xe6\xf9\xcf\xf7\x56\ +\x5c\xb1\x84\xc2\x72\x6a\xf6\xca\xa3\xab\xe6\x72\xa9\x24\x19\x2e\ +\x71\x75\xd7\xa5\xe9\x35\xe2\x6b\x92\x27\xe4\x42\x63\xb6\x97\x36\ +\x20\xb4\x1d\x92\xf6\x67\x4e\x9f\x27\x45\x32\x9b\xc7\x7d\xde\x49\ +\xec\xdc\xaa\x99\x2d\xfd\xfa\x11\x10\xb3\x7d\x06\x08\x0f\x2e\xd0\ +\x47\xb4\x5c\x4b\x0b\x62\xde\x30\x66\xee\x0b\x9c\xaf\xb3\xf4\x88\ +\x61\x22\xb4\x22\x37\x6b\x2d\x91\x6d\x95\x55\x2a\xdc\x63\x5d\x6d\ +\x5d\x51\xc8\x59\x9d\x4b\xeb\x13\x97\x8a\x22\x0b\x36\x75\x90\x2a\ +\x9c\xce\xde\x70\x4f\xc4\xfb\x1a\x1e\x33\x7e\xbb\x1a\x63\xb7\x89\ +\x9c\xe3\xd2\x56\xaa\x59\x77\x28\x57\x7b\x4e\x91\x4c\x28\x9d\xf3\ +\x17\x73\x23\x0e\x4c\xfd\x52\xe9\xab\x20\xd0\x95\x96\x4f\x81\xb2\ +\xae\xa1\x32\xe4\x04\xde\x4f\x17\x60\xcb\xb6\x90\x55\x5a\x3d\xd0\ +\x13\x49\xa5\xb9\x5d\xa3\x69\x1e\x58\x98\xf6\x45\x5c\x56\xf5\x31\ +\x8a\x58\x68\xc2\x6a\x16\xb2\xa7\xa0\x77\x5a\x89\x85\x1f\xeb\xd3\ +\x66\xe8\x24\xea\xba\xa9\x55\xe3\x83\x7c\x71\xf9\x24\x25\x03\x99\ +\xcd\xb9\x36\x20\x87\x64\xe9\xcb\x47\x50\x1c\x11\x46\x46\x3a\xb6\ +\xa4\xd5\x6f\xf3\x13\x39\xa8\x1f\x07\x55\x5f\xbe\x90\x27\x1c\xeb\ +\x76\xa3\xe9\xe6\xc6\xcc\xd7\x97\x7d\x90\xd7\xee\xec\xe0\xba\x3b\ +\x79\x69\xb7\x08\xff\x2f\x71\x19\x94\x0a\xa3\x4a\xff\x71\x4a\x3d\ +\xe5\x29\x4f\x79\xca\x53\x9e\xf2\x94\xa7\x3c\xe5\x29\x4f\x79\xca\ +\x53\x9e\xf2\x94\xa7\x3c\xe5\xff\x51\x36\x10\x58\x1a\xf9\x96\x46\ +\x45\x1a\x96\xc7\xb5\xb5\x30\x89\xcd\x3a\x76\xc4\x5a\x29\xd1\x35\ +\x6a\xce\xe8\x94\x57\xcc\xb2\xe6\x5f\xc2\x0d\xa2\x77\x06\x57\xbe\ +\xee\x7d\xb0\x01\xd6\xf5\xb4\x78\xad\xd6\x3a\x57\x38\xa7\x70\x1b\ +\x5e\x89\xfc\xed\x92\x3a\xdb\x0b\x8d\x68\xf9\x12\x45\xf5\xd9\x8c\ +\xdf\x7f\xce\xfe\x7e\x27\xfc\x73\xf6\xd7\x6d\xf1\x69\x17\x37\x93\ +\xee\x7d\x3a\x9f\x4e\xfa\xf7\x29\x49\x3e\x6e\xbf\xc9\xb7\xbf\x6e\ +\xbf\x47\xf5\xa9\xbb\xdd\x5e\x9f\xfa\xf1\x81\x4d\x8c\x0b\x7f\xfd\ +\x3c\x84\x4d\x36\x49\xde\xa7\xfa\x62\x42\x92\xde\x22\x64\x26\xcc\ +\x64\xbc\x1e\x27\x27\xcb\x3f\x97\x7f\x90\xe3\xc4\x71\x0a\x7e\x9c\ +\xaf\xe9\xaf\xff\x00\x91\xcf\x15\xcf\ +\x00\x00\x08\xae\ +\x00\ +\x00\x1f\x82\x78\x9c\xed\x59\x59\x8f\xea\x3a\x12\xfe\x2b\x88\xd7\ +\xd6\x0c\x49\x08\x74\x73\x75\xfa\x48\x76\x36\x12\xb2\x90\x00\x59\ +\xfc\x96\x05\xb2\x3a\x61\x09\x64\xf9\xf5\xe3\x40\x37\xf7\xdc\x73\ +\xef\x3c\xcc\xf3\x50\x12\x22\x29\xdb\xe5\xaa\xaf\xaa\x3e\x47\xf2\ +\x8f\xcb\x2d\x1e\xb5\xb8\x28\x2f\x9f\xe3\xa4\xae\x8f\x7f\x4c\x26\ +\x4d\xd3\xfc\xbb\x99\xfe\xbb\x3a\xc7\x13\x86\xa2\xa8\x09\x99\x31\ +\x1e\x35\x69\x54\x27\x9f\x63\x86\x1d\x8f\x92\x7d\x1a\x27\xf5\xe3\ +\xf9\x96\xee\x1b\x58\xb5\x9f\x63\x6a\x44\x8d\x18\x76\x34\xe8\x0e\ +\x69\x51\x7c\x8e\xcb\xaa\xdc\x8f\x47\x97\xfa\x5c\xe5\xfb\xcf\x71\ +\x78\x3d\x9f\xf7\x65\xcd\x55\x45\x75\xfe\xd6\xfe\xeb\xdb\xe6\x53\ +\x51\xa4\xe5\x3e\xf4\x8f\x9f\xe3\x73\x75\x2d\xa3\xbf\xa8\xb3\x2a\ +\x2d\x9f\xfa\xbb\xbf\x7f\x84\xcc\xd1\x7f\x3a\x3d\xbc\xdc\x5d\xc6\ +\x7e\x99\x1e\xf6\x97\x7a\xfc\xf3\x07\xde\xd7\x7e\xe4\xd7\xfe\xcf\ +\x1f\xc3\xe8\x1f\xdf\x23\x3f\x01\x00\x4e\x8c\x8f\x74\xe0\xc8\xe4\ +\x11\xec\xfd\xd2\xae\x91\xa4\x75\xa1\x24\x00\xc1\x04\x66\x4c\x94\ +\xa7\x18\xb0\x78\x2a\xf0\x5e\xaf\x34\x9e\x49\x14\x10\x85\xc3\x3c\ +\x6f\x18\x14\xa2\x53\xe4\xd0\x39\x19\xab\xc9\x98\x05\x20\x47\x94\ +\xdc\x09\xf0\x69\x10\x9a\x3a\x1d\x62\x76\x7e\x5f\xb7\xad\xa6\x7a\ +\xa6\xe4\x9a\xa3\x4d\x75\x8e\xea\x0c\x3e\xca\xd4\xad\x95\x19\x8e\ +\x56\x1b\xdb\x22\xd3\x3a\x3a\xf5\xb0\x95\x7b\x5b\x58\x78\x0e\xca\ +\x0d\xde\x1b\xfc\xe1\x0b\xe6\xe9\xdb\xf1\xdb\x37\xcf\xd5\xfe\xd9\ +\xb7\xcd\x2c\x09\xa7\x7a\x11\x96\xd6\x31\x60\x66\xfd\xb0\x08\xa8\ +\xed\xe0\x5f\x3a\x3c\x5a\xd2\x60\xcb\xca\x3c\xbc\xe8\x00\x24\xf1\ +\x0d\x66\x57\x15\x30\x94\xba\x15\x98\x7b\xcc\x57\xdf\x99\x95\x21\ +\xb6\x73\xdf\xb1\xaf\x11\x37\x63\xb4\xe6\x6e\xc5\x23\xf6\x7b\x7f\ +\x39\x3d\x9c\xf9\x78\xfb\x21\xb4\x6e\xff\x66\xed\x45\xf6\xf6\xb6\ +\xa1\xa9\x01\x90\xc6\x63\x94\x5b\x78\xd2\x8f\xc4\xbf\x39\x22\xf6\ +\x03\x47\xa4\x02\xa7\xa8\x3d\x27\x2a\xd4\xa9\xce\xa0\xae\x66\x03\ +\xa7\x3d\x11\xfb\x7d\x24\x89\x57\x8f\xb1\x15\x6b\x19\x5f\xf6\x12\ +\xdd\x18\xb8\x38\x22\xfe\x98\x6b\xdb\x70\x6a\x6c\xc5\x54\xef\x28\ +\x46\xdf\xee\xa6\xea\xd6\x9c\xea\x92\x56\x7b\x59\xce\x68\x1c\x45\ +\xe9\xcc\x8e\x46\x58\x68\x35\xf2\xf3\x18\xf1\x42\xfc\xbc\x78\xee\ +\x23\x56\x5f\x2a\x1a\x7f\x09\x13\xa2\xbb\x46\xc2\x02\x0f\x4e\xb1\ +\xcf\xbc\x42\xf8\xc0\x4e\x4f\x83\xa9\xfc\x3b\x76\xdb\x6f\xec\xbc\ +\xe9\xc3\x96\x5a\x7a\xdd\x3d\x6a\x98\x64\x5a\xa9\x57\x91\xfe\x1e\ +\xa1\x40\xd8\xb0\xda\xfb\x09\x7e\xbc\xd9\x5b\xf9\xfc\xa6\x0e\xb8\ +\xa0\xe2\x8e\x65\x85\xa3\xe7\xda\xf8\xa4\x60\xcf\xd1\x29\xdf\x59\ +\x5c\xfd\x2f\x4c\x83\x29\x2c\x82\x01\xd3\x72\xf0\x4f\xac\x91\x6b\ +\x91\x1c\x19\x89\xcf\x14\x57\x34\x55\x0a\x24\x15\x45\x50\x5a\x7d\ +\xec\x2b\x59\xe4\x2a\x97\x3d\x47\xf7\xc8\x69\xb1\x7c\xcf\xbd\x82\ +\xd7\xce\xc3\x8e\xe7\xea\x3d\x72\x95\xbb\xed\xb0\x5b\x10\xdf\x60\ +\xa2\xe2\x5f\x6d\x98\xd7\x28\xd3\x73\x5f\x12\x7b\x5f\x8c\xe3\xa4\ +\x59\x05\xdc\xd5\xd0\xd7\x8b\xc9\x4d\x02\x66\xd7\x9f\x11\xd8\x18\ +\xd9\xd5\x97\x58\xf4\xb6\x51\xe5\xdb\xb2\x99\x31\xe7\xd5\x56\x0c\ +\xb9\x1c\x61\x31\x8b\xa4\xe2\x16\x94\x71\xe4\x31\x8b\x5a\xc5\xe2\ +\x35\x92\x92\x8e\xf8\x7e\xf4\xba\x59\x16\x48\x22\x8d\xa4\xdd\x35\ +\x5c\x2a\xb7\x08\x17\x39\x72\xac\x53\x28\x89\x9d\xe7\xd0\x45\x24\ +\xd9\x5d\x38\x17\xd9\x25\xa3\xdf\x02\x82\x23\x89\xa5\x0a\xf1\xa2\ +\xf1\x1d\x8d\xc4\xae\x1c\x11\x89\x53\x75\xc8\x18\x46\xa4\x3e\xec\ +\x21\xf7\x65\xe4\xcc\xce\x01\x5e\x4c\x03\x5c\xe7\xc8\xd5\xb3\x10\ +\x17\xcd\x63\xff\x04\x9b\x4c\x9b\x44\x8e\x55\xc8\x4b\x48\xf6\xbf\ +\xaf\xc9\xe5\xa5\x55\xf9\xae\x16\x23\x5c\x5c\xd0\x06\x26\x11\x07\ +\x29\x5f\xda\xc5\x24\xdf\x6d\xe4\xd8\x3d\x79\xbf\xa1\x14\x26\xf2\ +\xd2\x26\x18\xc9\xb1\xe7\xcc\x72\x59\xa2\x93\xfd\x06\x56\x9e\x8b\ +\x0a\x59\xd2\x3b\x44\x6a\x12\x39\x66\x4c\x6a\x20\x0e\x9c\x05\xa9\ +\x71\x62\x9b\xbc\x93\x38\xc9\x38\x3a\x06\xc4\x1e\x89\x9d\xc4\x4d\ +\x6a\x68\x49\x7c\x77\xf5\x1b\x2a\xad\xa9\xe7\x2a\x85\x49\xea\x38\ +\x28\x37\x09\x92\x48\x7c\x8e\xfd\xf4\x91\xe4\xf3\x12\x08\xdf\xd8\ +\xe9\x4a\x80\xf5\xcb\xa0\x47\xeb\xdd\xd0\xbe\xf2\x9f\x3c\x01\x1f\ +\x3c\x31\xf4\x49\xfc\x1b\x4f\x58\xfa\x57\x2e\x93\x24\x64\xe2\x2b\ +\x92\x44\xea\xce\x33\xdf\x7d\x07\xd4\x26\xb9\xda\xd9\x49\xd1\x75\ +\x01\x16\x40\x31\x2f\xe9\x7e\x1a\x0e\xeb\xc1\xa3\xef\xec\xcc\x73\ +\xda\x12\x91\x5a\xf5\xb6\x32\xad\x63\xbd\xf1\x1c\x4b\x07\xbf\x8b\ +\x64\x11\x2c\xf4\xca\xe1\xb8\xa3\x95\x43\xc7\x64\x70\x4e\x45\x47\ +\xda\x04\xa5\x73\xed\xc4\xcd\x76\xcf\x60\x3f\xb2\xeb\xdb\xee\x50\ +\xb7\x3d\xb3\x9a\xb0\xe0\x6c\x49\xd6\x75\xc8\x71\x80\x1f\xb5\x38\ +\x60\x1a\xe0\x02\x93\x9c\x51\x3e\xb6\x59\x8f\x69\xe9\x90\x19\x62\ +\x37\x60\x85\xed\xa1\xb7\xbb\x08\x66\x14\xc2\x2d\x59\x13\x51\x3e\ +\xcc\xf7\x70\xd8\x3b\x9b\x3d\x79\x48\x61\x1e\x3c\xa4\x11\xac\x7e\ +\xe7\x21\xf0\xd5\x33\xc4\x6e\xe2\x3b\x14\xa9\xe7\x3b\xf7\x71\x3c\ +\x33\xf4\xee\xaa\x18\xfa\x1e\x31\x88\xf4\xbb\xd0\xe9\x5b\x74\x24\ +\x7b\x97\x01\x16\x29\xd2\x37\xc5\x5e\xf8\xad\x6f\x36\x5f\x3d\x32\ +\xb5\xbb\x20\x3b\xde\x9f\x8d\x2c\x64\x34\x6c\xb6\x5e\x1f\x52\xea\ +\x56\x66\x75\x46\xab\x09\xc7\xcc\xbc\x8e\x9a\x11\x2e\xee\x55\x47\ +\x49\x91\x64\x25\x9a\x64\x27\x08\x9b\xac\x9e\x7e\xd9\x28\x09\xb7\ +\x31\xb3\x24\x5a\xda\xa4\x8e\x8e\x47\x92\x6b\x8a\xd4\x58\x86\x76\ +\x85\xb0\xe7\x5a\x36\x70\xc1\xdc\x77\x8a\xdc\xc8\x94\x44\xc3\x5a\ +\x8b\x24\xaf\x21\x35\x9f\xe9\xbc\x5c\xeb\x7c\xc8\x1a\x1c\x9d\x22\ +\x47\x26\xf6\x05\xc6\xeb\xa3\xd4\xdb\x0a\x33\x4d\x12\x53\x54\x2a\ +\xa4\xf6\xed\x84\xd4\x5c\xee\x32\x62\x1f\x32\x76\xf7\xa8\x25\x83\ +\xaf\xf0\x70\x7e\xb4\xac\xea\xea\x45\x20\x79\xd9\xbd\x96\xb0\xb7\ +\xf8\x27\xae\x57\xff\x1b\x7f\x33\xcf\x9c\x2f\xc1\xf1\xb2\x62\x59\ +\x6a\x32\x7d\xfb\xf0\x20\xaf\x1a\x75\x0e\xe5\xb9\x31\xab\x82\xa4\ +\x9c\xc9\x97\xf4\xe3\xc0\x97\xfe\xe5\xa8\xd9\xcd\xea\x9b\x87\x8e\ +\xff\x03\x0f\x7d\xf7\x80\x46\x72\xa6\x3c\x39\x68\xdd\x45\xf2\x1a\ +\x08\xd5\x12\xa9\x6b\xc3\xb0\xba\xf4\x6a\x7c\x2c\x22\xff\xc8\x9f\ +\xfa\x22\xf7\x05\x91\xc9\x9a\x78\x75\xb1\x26\x25\x9d\xa6\xde\x90\ +\xa7\x65\x4c\x72\x6a\x5f\x50\xaa\xdf\xeb\x05\x65\xf4\xb7\xfd\x5f\ +\xb0\xd1\x6e\x5f\x18\xdc\x63\x53\xb1\x45\xf0\x13\x9f\x7b\xce\x2b\ +\xee\xb8\x9d\xe4\x1f\xa8\x8b\xcd\x33\xf2\x3a\xc8\x54\x5c\x7f\xd1\ +\x19\x35\x92\x96\x39\xb5\x88\x16\xe1\x5a\x78\x6f\xa0\xde\x1b\x24\ +\x87\xf7\x3a\xa3\x0f\x88\xb1\xaf\x24\x26\x62\x67\xd1\xb9\x03\x2f\ +\xe3\xb7\xec\xab\xd7\x6f\xa6\x33\xa3\x7c\xc2\x7b\xa1\x54\x64\xb2\ +\x30\xf0\x04\xe1\x98\x29\x22\xd8\xeb\x03\xf6\x85\xb6\x61\x1b\x35\ +\x83\xe7\x90\xf0\xbc\x47\xff\xa2\x4f\x59\x4a\xcd\x86\x73\x04\xb0\ +\x4f\x1e\xe0\x92\xef\xef\x85\x2c\x6c\xfe\xf6\xbd\xf0\xf7\x5a\xbb\ +\x7f\x6f\x00\xf9\xde\xe7\xf5\x4a\x70\x4c\x79\x53\xc5\x02\x96\x38\ +\x11\x81\xb8\x6a\xe2\x58\x96\x34\x59\x86\x99\x0f\x78\x10\x0b\x1c\ +\x48\x2c\x30\xf3\xc1\xea\xfd\x22\xcf\x28\x75\xce\xc6\x93\x4a\x32\ +\xe3\xdd\x02\xd1\xbb\x2d\x99\xb1\x82\x71\x7c\x4a\xf2\xcc\x58\x9b\ +\x26\x0f\x7a\xa8\x68\x56\xd8\x88\xa6\xc7\xdb\xa6\xb9\x12\x9a\x19\ +\xfc\x85\xbb\x2f\xb2\x08\x39\xb3\x17\xae\x1a\xd7\x48\x80\xde\x09\ +\xa0\xd5\x8a\xbf\x62\x41\x38\xbd\xb4\x86\x73\x27\x36\xef\x67\x10\ +\xa9\x39\xa9\x48\x82\xa5\x16\xef\xf0\xe2\x46\x38\x99\x37\xb7\x60\ +\x2f\x36\x54\xa7\x67\x80\xd5\xf8\xb0\x35\x78\xb3\x27\x7d\xeb\x0f\ +\x3a\x83\x1f\x74\x5e\x6b\x6c\x1f\x3a\x4d\x30\x5b\xb1\x07\x36\x8c\ +\x75\x1b\x82\x6a\xcb\xe7\xbf\x9e\x41\x4d\xbc\x13\x14\x5e\xdb\xe4\ +\x0d\xc9\xfc\xe0\x2f\x2f\x74\xf0\x57\x7f\xe3\xe7\xb9\xf1\x8b\x5f\ +\xbb\x3b\x3f\x90\x7c\xf6\x10\x69\x50\x93\x60\x77\x92\x36\x1a\xbb\ +\x20\x58\x49\x1c\xf7\xf5\xdc\x08\x4b\x40\xc9\x00\x2a\xc9\x9c\xc3\ +\x37\x75\x07\x63\x51\xd4\x77\xd4\x6d\xb7\x2a\x0c\xbb\x16\xe6\x51\ +\x16\xd1\xef\xea\x6c\x73\x6d\x5c\x4a\xdd\x63\x31\xbd\x40\x6d\x1a\ +\xe5\xd1\x64\x1a\x76\x59\x2b\x82\xa9\x59\xcd\x36\xec\xfc\xe0\x52\ +\x93\x34\xf1\x28\x1b\xbd\xe3\x43\xb0\x58\x89\xef\xd4\xb4\x9e\x19\ +\x99\xc3\x43\x47\x03\xec\x80\x61\xc4\x37\x02\x9c\x34\xa6\x00\x1a\ +\x79\x19\xf3\x8f\x58\x97\x9b\x9d\xc0\x67\x40\x83\x71\x75\x86\xb1\ +\x20\x00\x6f\x5d\xb9\xb1\x0c\x35\x70\xc7\x3d\x12\x1e\x6b\x38\x0d\ +\x80\x66\x39\xc4\x6e\x51\x19\x84\x71\x23\x56\x60\xd7\x17\xcb\x14\ +\xca\xa2\x81\xc4\x4b\xf6\x66\xac\x85\x7e\x56\xbd\x95\xda\xd2\x75\ +\x35\xb9\xe1\x62\x4f\x5e\x55\x48\xee\x33\x8a\xec\xa7\xf1\xa8\x01\ +\x0d\x02\x72\xa3\xb9\x5a\x24\x2a\x2c\xdc\xd7\xaa\x6a\x7b\xef\x86\ +\x75\x15\x16\x65\x75\x0a\x02\x80\x0c\x54\x4e\x7c\x70\x76\xbb\x6d\ +\x23\x02\x74\x58\x9d\xd7\x7d\x2b\x32\xb7\x75\xa5\xe8\x07\xfa\x6d\ +\xc7\x45\x31\xed\x4a\x20\x83\x8d\x4b\xf7\xd1\xc2\x91\x4e\x5e\x7e\ +\xc2\x04\x19\xf1\xd4\xd0\x66\xb7\xc9\xce\x74\xaf\x1c\x8e\x8a\xb6\ +\x78\xa3\xcf\x24\xd6\xcd\x5a\xd5\x8c\xb5\x91\xad\xae\xa9\x9b\x55\ +\xbb\xc9\x71\xb7\xdb\x4b\x0b\xab\xc1\xe4\x7b\x09\x26\x48\xcc\xf5\ +\x32\xfe\xdb\x29\xf5\x92\x97\xbc\xe4\x25\x2f\x79\xc9\x4b\x5e\xf2\ +\x92\x97\xbc\xe4\x25\x2f\x79\xc9\x4b\x5e\xf2\x92\xff\x47\x59\x23\ +\xcf\x14\x4f\xa6\x20\x30\x66\x9f\x38\x27\x46\xf1\xda\x0f\x7d\x75\ +\xde\xe9\x5e\x50\xb8\x55\xc1\xcd\x8d\x44\x98\x1c\x0e\x94\x7a\x8a\ +\xf0\x3e\xaf\xa3\xaa\x9f\x23\x94\x9f\xb6\x40\xc7\xec\x44\xdf\xe4\ +\xb6\xaf\x6f\x9b\x37\xef\x6d\x5e\x7f\xb0\x11\x3e\xcc\xe1\xfe\x48\ +\x5b\x65\x67\x5e\xe3\xcf\x1f\x93\xbf\xde\x09\xff\x98\xfc\x79\x5b\ +\x7c\xde\x87\xf5\xa8\xfd\x1c\x4f\xc7\xa3\xee\x73\x4c\xd3\xcf\xdb\ +\x6f\xfa\xe3\xcf\xdb\xef\x41\x7d\x6e\xef\xb7\xd7\xe7\x6e\xf8\x23\ +\x26\x86\x85\x3f\x7f\x1c\xfd\x3a\x19\x45\x9f\x63\xed\x7d\x44\xd3\ +\xf6\xbb\x3f\x1b\xcd\x46\xc3\xf5\x38\x3d\xa2\xc9\xdf\x8d\x1d\x66\ +\x0e\x73\xc8\xdf\xe5\x16\xff\xfc\x0f\xfa\xcf\x14\x61\ \x00\x00\x01\x33\ \x3c\ \x73\x76\x67\x20\x78\x6d\x6c\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\ @@ -1462,6 +1744,14 @@ \x05\x77\x54\xa7\ \x00\x6c\ \x00\x6f\x00\x61\x00\x64\x00\x2e\x00\x73\x00\x76\x00\x67\ +\x00\x0a\ +\x05\x95\xd0\xa7\ +\x00\x75\ +\x00\x6e\x00\x6c\x00\x6f\x00\x63\x00\x6b\x00\x2e\x00\x73\x00\x76\x00\x67\ +\x00\x08\ +\x05\x9e\x54\xa7\ +\x00\x6c\ +\x00\x6f\x00\x63\x00\x6b\x00\x2e\x00\x73\x00\x76\x00\x67\ \x00\x08\ \x05\xa8\x57\x87\ \x00\x63\ @@ -1528,76 +1818,82 @@ qt_resource_struct_v1 = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x00\x18\x00\x02\x00\x00\x00\x14\x00\x00\x00\x03\ +\x00\x00\x00\x18\x00\x02\x00\x00\x00\x16\x00\x00\x00\x03\ \x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x04\ \x00\x00\x00\x4c\x00\x00\x00\x00\x00\x01\x00\x00\x01\x7d\ \x00\x00\x00\x6a\x00\x00\x00\x00\x00\x01\x00\x00\x03\x21\ \x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x00\x0a\xfe\ \x00\x00\x00\xb6\x00\x00\x00\x00\x00\x01\x00\x00\x0d\x56\ -\x00\x00\x00\xcc\x00\x00\x00\x00\x00\x01\x00\x00\x0e\xc6\ -\x00\x00\x00\xe2\x00\x01\x00\x00\x00\x01\x00\x00\x0f\xfd\ -\x00\x00\x00\xfc\x00\x00\x00\x00\x00\x01\x00\x00\x13\xa9\ -\x00\x00\x01\x14\x00\x00\x00\x00\x00\x01\x00\x00\x17\x48\ -\x00\x00\x01\x2a\x00\x00\x00\x00\x00\x01\x00\x00\x18\xd4\ -\x00\x00\x01\x3e\x00\x00\x00\x00\x00\x01\x00\x00\x1a\x48\ -\x00\x00\x01\x64\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xc1\ -\x00\x00\x01\x7a\x00\x00\x00\x00\x00\x01\x00\x00\x25\x7f\ -\x00\x00\x01\x94\x00\x00\x00\x00\x00\x01\x00\x00\x2a\x45\ -\x00\x00\x01\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x33\xd8\ -\x00\x00\x01\xd6\x00\x00\x00\x00\x00\x01\x00\x00\x3b\x79\ -\x00\x00\x01\xf2\x00\x00\x00\x00\x00\x01\x00\x00\x3d\x0d\ -\x00\x00\x02\x0c\x00\x00\x00\x00\x00\x01\x00\x00\x3e\x87\ -\x00\x00\x02\x2c\x00\x00\x00\x00\x00\x01\x00\x00\x46\x28\ -\x00\x00\x02\x54\x00\x00\x00\x00\x00\x01\x00\x00\x4c\x78\ +\x00\x00\x00\xcc\x00\x01\x00\x00\x00\x01\x00\x00\x0e\xc6\ +\x00\x00\x00\xe6\x00\x01\x00\x00\x00\x01\x00\x00\x17\x74\ +\x00\x00\x00\xfc\x00\x00\x00\x00\x00\x01\x00\x00\x20\x26\ +\x00\x00\x01\x12\x00\x01\x00\x00\x00\x01\x00\x00\x21\x5d\ +\x00\x00\x01\x2c\x00\x00\x00\x00\x00\x01\x00\x00\x25\x09\ +\x00\x00\x01\x44\x00\x00\x00\x00\x00\x01\x00\x00\x28\xa8\ +\x00\x00\x01\x5a\x00\x00\x00\x00\x00\x01\x00\x00\x2a\x34\ +\x00\x00\x01\x6e\x00\x00\x00\x00\x00\x01\x00\x00\x2b\xa8\ +\x00\x00\x01\x94\x00\x00\x00\x00\x00\x01\x00\x00\x31\x21\ +\x00\x00\x01\xaa\x00\x00\x00\x00\x00\x01\x00\x00\x36\xdf\ +\x00\x00\x01\xc4\x00\x00\x00\x00\x00\x01\x00\x00\x3b\xa5\ +\x00\x00\x01\xe2\x00\x00\x00\x00\x00\x01\x00\x00\x45\x38\ +\x00\x00\x02\x06\x00\x00\x00\x00\x00\x01\x00\x00\x4c\xd9\ +\x00\x00\x02\x22\x00\x00\x00\x00\x00\x01\x00\x00\x4e\x6d\ +\x00\x00\x02\x3c\x00\x00\x00\x00\x00\x01\x00\x00\x4f\xe7\ +\x00\x00\x02\x5c\x00\x00\x00\x00\x00\x01\x00\x00\x57\x88\ +\x00\x00\x02\x84\x00\x00\x00\x00\x00\x01\x00\x00\x5d\xd8\ " qt_resource_struct_v2 = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x01\x9d\xe0\x78\xcb\x77\ -\x00\x00\x00\x18\x00\x02\x00\x00\x00\x14\x00\x00\x00\x03\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x00\x18\x00\x02\x00\x00\x00\x16\x00\x00\x00\x03\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x04\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ \x00\x00\x00\x4c\x00\x00\x00\x00\x00\x01\x00\x00\x01\x7d\ -\x00\x00\x01\x9d\xe0\x78\xcb\x75\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ \x00\x00\x00\x6a\x00\x00\x00\x00\x00\x01\x00\x00\x03\x21\ -\x00\x00\x01\x9d\xe0\x78\xcb\x77\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ \x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x00\x0a\xfe\ -\x00\x00\x01\x9e\x65\xc8\xf1\x97\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ \x00\x00\x00\xb6\x00\x00\x00\x00\x00\x01\x00\x00\x0d\x56\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ -\x00\x00\x00\xcc\x00\x00\x00\x00\x00\x01\x00\x00\x0e\xc6\ -\x00\x00\x01\x9d\xe0\x78\xcb\x75\ -\x00\x00\x00\xe2\x00\x01\x00\x00\x00\x01\x00\x00\x0f\xfd\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ -\x00\x00\x00\xfc\x00\x00\x00\x00\x00\x01\x00\x00\x13\xa9\ -\x00\x00\x01\x9d\xe0\x78\xcb\x77\ -\x00\x00\x01\x14\x00\x00\x00\x00\x00\x01\x00\x00\x17\x48\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ -\x00\x00\x01\x2a\x00\x00\x00\x00\x00\x01\x00\x00\x18\xd4\ -\x00\x00\x01\x9d\xe0\x78\xcb\x77\ -\x00\x00\x01\x3e\x00\x00\x00\x00\x00\x01\x00\x00\x1a\x48\ -\x00\x00\x01\x9e\x8e\x14\x03\xf8\ -\x00\x00\x01\x64\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xc1\ -\x00\x00\x01\x9d\xe0\x78\xcb\x77\ -\x00\x00\x01\x7a\x00\x00\x00\x00\x00\x01\x00\x00\x25\x7f\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ -\x00\x00\x01\x94\x00\x00\x00\x00\x00\x01\x00\x00\x2a\x45\ -\x00\x00\x01\x9d\xe0\x78\xcb\x75\ -\x00\x00\x01\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x33\xd8\ -\x00\x00\x01\x9d\xe0\x78\xcb\x75\ -\x00\x00\x01\xd6\x00\x00\x00\x00\x00\x01\x00\x00\x3b\x79\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ -\x00\x00\x01\xf2\x00\x00\x00\x00\x00\x01\x00\x00\x3d\x0d\ -\x00\x00\x01\x9d\xe0\x78\xcb\x75\ -\x00\x00\x02\x0c\x00\x00\x00\x00\x00\x01\x00\x00\x3e\x87\ -\x00\x00\x01\x9d\xe0\x78\xcb\x75\ -\x00\x00\x02\x2c\x00\x00\x00\x00\x00\x01\x00\x00\x46\x28\ -\x00\x00\x01\x9d\xe0\x78\xcb\x77\ -\x00\x00\x02\x54\x00\x00\x00\x00\x00\x01\x00\x00\x4c\x78\ -\x00\x00\x01\x9d\xe0\x78\xcb\x76\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x00\xcc\x00\x01\x00\x00\x00\x01\x00\x00\x0e\xc6\ +\x00\x00\x01\xa0\xe8\xb4\x4b\xc5\ +\x00\x00\x00\xe6\x00\x01\x00\x00\x00\x01\x00\x00\x17\x74\ +\x00\x00\x01\xa0\xe8\xb4\x4b\xc5\ +\x00\x00\x00\xfc\x00\x00\x00\x00\x00\x01\x00\x00\x20\x26\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x01\x12\x00\x01\x00\x00\x00\x01\x00\x00\x21\x5d\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x01\x2c\x00\x00\x00\x00\x00\x01\x00\x00\x25\x09\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x01\x44\x00\x00\x00\x00\x00\x01\x00\x00\x28\xa8\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x01\x5a\x00\x00\x00\x00\x00\x01\x00\x00\x2a\x34\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x01\x6e\x00\x00\x00\x00\x00\x01\x00\x00\x2b\xa8\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x01\x94\x00\x00\x00\x00\x00\x01\x00\x00\x31\x21\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x01\xaa\x00\x00\x00\x00\x00\x01\x00\x00\x36\xdf\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x01\xc4\x00\x00\x00\x00\x00\x01\x00\x00\x3b\xa5\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x01\xe2\x00\x00\x00\x00\x00\x01\x00\x00\x45\x38\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x02\x06\x00\x00\x00\x00\x00\x01\x00\x00\x4c\xd9\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x02\x22\x00\x00\x00\x00\x00\x01\x00\x00\x4e\x6d\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x02\x3c\x00\x00\x00\x00\x00\x01\x00\x00\x4f\xe7\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ +\x00\x00\x02\x5c\x00\x00\x00\x00\x00\x01\x00\x00\x57\x88\ +\x00\x00\x01\xa0\xac\x0a\x1d\x64\ +\x00\x00\x02\x84\x00\x00\x00\x00\x00\x01\x00\x00\x5d\xd8\ +\x00\x00\x01\xa0\xac\x0a\x1d\x63\ " qt_version = [int(v) for v in QtCore.qVersion().split(".")] diff --git a/src/instrumentserver/resource.qrc b/src/instrumentserver/resource.qrc index 35d5186..39a7e7f 100644 --- a/src/instrumentserver/resource.qrc +++ b/src/instrumentserver/resource.qrc @@ -21,5 +21,7 @@ resource/icons/trash.svg resource/icons/trash-crossed.svg resource/icons/folder.svg + resource/icons/lock.svg + resource/icons/unlock.svg \ No newline at end of file diff --git a/src/instrumentserver/resource/icons/lock.svg b/src/instrumentserver/resource/icons/lock.svg new file mode 100644 index 0000000..175b292 --- /dev/null +++ b/src/instrumentserver/resource/icons/lock.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo3NjJkMWM3NC0yODdjLTRjOWMtOTljMy1iYmRkYTBlYWZkODYAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaH3frDgT8ExXz+ReF4v+S10AAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDpkMTc3OTFiNy02NTU3LTQ3NGMtYjk2MC00N2U1ZmExMmExY2FscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNodN7dZbES4M7qB8+VTIr+LwAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFgghwKbCuONP9/vGAQyzrZASOjuaG4Z+SLIvHw52rKTFcCkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaALwhuVjqJNNEBlAJQsie3cAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCCpRkBWQ2mk0dp1QAnWuyFSTe2madVtvUftxz2K/4ArRGRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBj0Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjc2MmQxYzc0LTI4N2MtNGM5Yy05OWMzLWJiZGRhMGVhZmQ4Ni9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOjJhMmMxZGYwLWNjNDItNDc4OC1iZWIzLWE2YzdiYTE5MGFiZnJjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCCHApsK440/3+8YBDLOtkBI6O5obhn5Isi8fDnaspMVwKJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggPydIPAEoHZLPOORyiuO89dapDqzlkaEF2jwgKsR/n1iiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFgg6oCpT/k8ZygQrZYyB2oCzsN2LdGHk09d9cPE7wBNzON0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQFqQEE2QzhWq2JYx8NKrUNYblXolC6OhE/ff0Lqdmektdoz6ZZkqTANm4/NSkVaNTw+Y+6t84dmf6Bep1RnyQug= \ No newline at end of file diff --git a/src/instrumentserver/resource/icons/unlock.svg b/src/instrumentserver/resource/icons/unlock.svg new file mode 100644 index 0000000..cac7f69 --- /dev/null +++ b/src/instrumentserver/resource/icons/unlock.svg @@ -0,0 +1 @@ +AAAWgmp1bWIAAAAeanVtZGMycGEAEQAQgAAAqgA4m3EDYzJwYQAAABZcanVtYgAAAEdqdW1kYzJtYQARABCAAACqADibcQN1cm46YzJwYTo5YzY4N2ZkMy1hNWY1LTQxMzQtOTMyMS0xMDgxZGExNjNkNTcAAAADl2p1bWIAAAApanVtZGMyYXMAEQAQgAAAqgA4m3EDYzJwYS5hc3NlcnRpb25zAAAAALxqdW1iAAAARGp1bWRjYm9yABEAEIAAAKoAOJtxE2MycGEuaW5ncmVkaWVudC52MwAAAAAYYzJzaIY5h/ZTVYVZV1Ao3LYJzbcAAABwY2JvcqNpZGM6Zm9ybWF0bWltYWdlL3N2Zyt4bWxqaW5zdGFuY2VJRHgseG1wOmlpZDplZjlmYzJhZi04MTI0LTQ4OGItYmZjNy01MGYwMTUxNDVkNzhscmVsYXRpb25zaGlwaHBhcmVudE9mAAAB4mp1bWIAAABBanVtZGNib3IAEQAQgAAAqgA4m3ETYzJwYS5hY3Rpb25zLnYyAAAAABhjMnNokypG5rVB+pnNxNzNRVFPLAAAAZljYm9yomdhY3Rpb25zgqJmYWN0aW9ua2MycGEub3BlbmVkanBhcmFtZXRlcnOha2luZ3JlZGllbnRzgaJjdXJseC1zZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmluZ3JlZGllbnQudjNkaGFzaFggIdZ9yJkU6/kniuRFoNsdT7K2u/WIKDDcQZCXwO1R3dCkZmFjdGlvbngdY29tLmFudGhyb3BpYy5jbGF1ZGUucHJvdmlkZWRqcGFyYW1ldGVyc6F4H2NvbS5hbnRocm9waWMub3JpZ2luLWNvbmZpZGVuY2VndW5rbm93bmtkZXNjcmlwdGlvbnhmQ2xhdWRlIHByb3ZpZGVkIHRoaXMgZmlsZSBhdCB0aGUgcmVxdWVzdCBvZiBhIHVzZXIgYW5kIG1heSBoYXZlIGNyZWF0ZWQgb3IgbW9kaWZpZWQgdGhlIGZpbGUgY29udGVudHMubXNvZnR3YXJlQWdlbnShZG5hbWVmQ2xhdWRlcmFsbEFjdGlvbnNJbmNsdWRlZPUAAADIanVtYgAAAEBqdW1kY2JvcgARABCAAACqADibcRNjMnBhLmhhc2guZGF0YQAAAAAYYzJzaIRzy/fWK4uT8d72zIojvFMAAACAY2JvcqVjYWxnZnNoYTI1NmNwYWRNAAAAAAAAAAAAAAAAAGRoYXNoWCC9EbQ54sPbtZgM7Xznv8gAULbt8i20YqEXolFpepwYlGRuYW1lbmp1bWJmIG1hbmlmZXN0amV4Y2x1c2lvbnOBomVzdGFydBj0Zmxlbmd0aBkeBAAAAj5qdW1iAAAAJ2p1bWRjMmNsABEAEIAAAKoAOJtxA2MycGEuY2xhaW0udjIAAAACD2Nib3KlY2FsZ2ZzaGEyNTZpc2lnbmF0dXJleE1zZWxmI2p1bWJmPS9jMnBhL3VybjpjMnBhOjljNjg3ZmQzLWE1ZjUtNDEzNC05MzIxLTEwODFkYTE2M2Q1Ny9jMnBhLnNpZ25hdHVyZWppbnN0YW5jZUlEeCx4bXA6aWlkOmM1ODViYTRkLTJhYzktNDA1My1hZmIzLWI1YjZjNmNjNjU4N3JjcmVhdGVkX2Fzc2VydGlvbnODomN1cmx4LXNlbGYjanVtYmY9YzJwYS5hc3NlcnRpb25zL2MycGEuaW5ncmVkaWVudC52M2RoYXNoWCAh1n3ImRTr+SeK5EWg2x1Psra79YgoMNxBkJfA7VHd0KJjdXJseCpzZWxmI2p1bWJmPWMycGEuYXNzZXJ0aW9ucy9jMnBhLmFjdGlvbnMudjJkaGFzaFggJ4O6CcHERdMUD/kEtQvp91yTD3DOR5nBw3ecuJchKRmiY3VybHgpc2VsZiNqdW1iZj1jMnBhLmFzc2VydGlvbnMvYzJwYS5oYXNoLmRhdGFkaGFzaFggk1NOJN5080WBNcCRUWwWQvJUFcTY8CINurdTDSrlkol0Y2xhaW1fZ2VuZXJhdG9yX2luZm+jZG5hbWVvQW50aHJvcGljIEZpbGVzZ3ZlcnNpb25lMS4wLjBrc3BlY1ZlcnNpb25lMi40LjAAABA4anVtYgAAAChqdW1kYzJjcwARABCAAACqADibcQNjMnBhLnNpZ25hdHVyZQAAABAIY2JvctKEWQISogEmGCFZAgowggIGMIIBjaADAgECAhRA5aAK7sI50L64g/oGQgU9Z1UTADAKBggqhkjOPQQDAzBJMRcwFQYDVQQKEw5BbnRocm9waWMsIFBCQzEuMCwGA1UEAxMlQW50aHJvcGljIENvbnRlbnQgQ3JlZGVudGlhbHMgUm9vdCBDQTAeFw0yNjA4MDcxODQzNTZaFw0yODA4MDYxOTQzNTZaMEQxFzAVBgNVBAoTDkFudGhyb3BpYywgUEJDMSkwJwYDVQQDEyBBbnRocm9waWMgQ2xhdWRlIENvbnRlbnQgU2lnbmluZzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJh6CmvLUBgFFNU0vUKlOVtE6djd17L5SuwX0LemFisBM3dkd/3cyjxFA3Qo5S46fX0/ihY0VZ7mfb9KF703t5OjWDBWMA4GA1UdDwEB/wQEAwIHgDAVBgNVHSUEDjAMBgorBgEEAYPoXgIBMAwGA1UdEwEB/wQCMAAwHwYDVR0jBBgwFoAUzlHiBIFOZFsj+OPEz5o+nMHXXMIwCgYIKoZIzj0EAwMDZwAwZAIwMXMdFJ4BetLLVY7ORuE9noqbbAZOZn/aArXyTwFAZfKrPzxF2vPoJNf1+UCdg1XGAjBwX1zd9WGqYkqmL5SFqw1QySjr1zJfpJM9+1rdDwSPLMOPOjKuiXjoU/pUUeG9RwmhY3BhZFkNngAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPZYQL18gbkgalqKQ7O0PKcTEoIlmvbtsmrjn59oC+aPm4eNBl6iWYPYQvr76nKMBkTraSav0j8ugTSu4m4GubbosOc= \ No newline at end of file diff --git a/src/instrumentserver/schemas/parameter_manager_v2.json b/src/instrumentserver/schemas/parameter_manager_v2.json new file mode 100644 index 0000000..f08a14f --- /dev/null +++ b/src/instrumentserver/schemas/parameter_manager_v2.json @@ -0,0 +1,92 @@ +{ + "type": "object", + "title": "Parameter Manager profile, version 2", + "description": "This schema validates the version-2 profile document the Parameter Manager writes and reads (plan decision D19): the parameters with their own values and the Lock of each Follower, and the Type registry.", + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "version": { + "const": 2, + "description": "File format version. Saving always writes version 2; a file without this key is the legacy flat parameter map." + }, + "parameters": { + "type": "object", + "description": "Map of full dotted parameter paths (with the instrument name) to the parameter's own value, its unit and -- only on Followers -- its Lock. Validated in depth against schemas/parameters.json by serialize.validateParameterManagerV2.", + "patternProperties": { + "^(\\w+)(\\.\\w+)*$": { + "type": "object", + "description": "One parameter: its own value, the metadata included by the writer, and its Lock when it is a Follower.", + "properties": { + "value": { + "type": ["string", "object", "null", "number", "boolean", "array"], + "description": "own value of the parameter" + }, + "unit": { + "type": "string", + "description": "unit of the parameter" + }, + "lock": { + "type": "object", + "description": "The parameter's Lock (present only on Followers, in either state).", + "properties": { + "target": { + "type": "string", + "description": "full dotted path of the Target" + }, + "locked": { + "type": "boolean", + "description": "whether the Lock is currently locked" + } + }, + "required": ["target", "locked"], + "additionalProperties": false + } + }, + "required": ["value"] + } + } + }, + "types": { + "type": "object", + "description": "The Type registry: every Type with its entries and its Nested Types.", + "additionalProperties": { + "type": "object", + "description": "One Type: its own entries by relative parameter path, and its Nested Types.", + "properties": { + "parameters": { + "type": "object", + "description": "The Type's own entries by relative parameter path.", + "additionalProperties": { + "type": "object", + "description": "One entry: its default value, its unit, and the full dotted Target of its Type Lock (null when it has none).", + "properties": { + "default": { + "description": "default value of the entry" + }, + "unit": { + "type": "string", + "description": "unit of the entry" + }, + "target": { + "type": ["string", "null"], + "description": "Target of the entry's Type Lock, in the full dotted form, or null" + } + }, + "required": ["default", "unit", "target"], + "additionalProperties": false + } + }, + "nested": { + "type": "object", + "description": "Nested Types: the submodule name that requires them mapped to the nested Type's name.", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["parameters", "nested"], + "additionalProperties": false + } + } + }, + "required": ["version", "parameters", "types"] +} diff --git a/src/instrumentserver/serialize.py b/src/instrumentserver/serialize.py index c0bb845..96b80fd 100644 --- a/src/instrumentserver/serialize.py +++ b/src/instrumentserver/serialize.py @@ -75,7 +75,7 @@ from qcodes import Parameter, Station from qcodes.instrument import InstrumentBase -from . import PARAMS_SCHEMA_PATH +from . import PARAMS_SCHEMA_PATH, PM_V2_SCHEMA_PATH logger = logging.getLogger(__name__) @@ -216,6 +216,25 @@ def validateParamDict(params: Dict[str, Any]) -> None: raise +def validateParameterManagerV2(document: Dict[str, Any]) -> None: + """Validate a version-2 Parameter Manager profile document (see + :mod:`.params`, plan decision D19). + + The whole document is validated against + ``schemas/parameter_manager_v2.json`` (the ``version`` key, the + ``lock`` entry of a Follower and the ``types`` section); the + ``parameters`` map is then validated against the per-parameter schema + ``schemas/parameters.json`` like a legacy flat parameter map. + + :param document: The profile document, with the keys ``version`` + (2), ``parameters`` and ``types``. + """ + with open(PM_V2_SCHEMA_PATH) as f: + schema = json.load(f) + validate(document, schema) + validateParamDict(document["parameters"]) + + def toDataFrame(input: SerializableType) -> "pd.DataFrame": """Make a pandas data frame from the parameters. Mainly useful for printing overviews in notebooks.""" diff --git a/src/instrumentserver/server/core.py b/src/instrumentserver/server/core.py index 52342bb..2115730 100644 --- a/src/instrumentserver/server/core.py +++ b/src/instrumentserver/server/core.py @@ -38,6 +38,10 @@ from ..blueprints import ( INSTRUMENT_MODULE_BASE_CLASSES, PARAMETER_BASE_CLASSES, + PARAMETER_CALL, + PARAMETER_CREATION, + PARAMETER_DELETION, + PARAMETER_UPDATE, CallSpec, InstrumentCreationSpec, InstrumentModuleBluePrint, @@ -145,6 +149,12 @@ def __init__( if settings["initialize"]: self.station.load_instrument(instrumentName) + # Instruments that reached the Station from config implement the + # Broadcaster contract (ADR-0003) or not; register the server as a + # Broadcast sink on the ones that do. + for component in self.station.components.values(): + self._registerBroadcaster(component) + self.allowUserShutdown = allowUserShutdown self.listenAddresses = list(set(["127.0.0.1"] + addresses)) self.initScript = initScript @@ -185,6 +195,7 @@ def __init__( self._wakeup_w.setblocking(False) # Per-instrument locks to avoid races when multiple threads talk to the same instrument concurrently + # Prose calls these the "instrument mutex" (ADR-0003); the code keeps its current names. self._instrument_locks: dict[str, threading.RLock] = {} self._instrument_locks_lock = threading.Lock() @@ -459,6 +470,7 @@ def _createInstrument(self, spec: InstrumentCreationSpec) -> None: if new_instrument.name not in self.station.components: self.station.add_component(new_instrument) + self._registerBroadcaster(new_instrument) self.instrumentCreated.emit( bluePrintFromInstrumentModule(new_instrument.name, new_instrument), @@ -485,7 +497,7 @@ def _invoke() -> Any: # Broadcast changes in parameter values. self._broadcastParameterChange( ParameterBroadcastBluePrint( - spec.target, "parameter-update", args[0] + spec.target, PARAMETER_UPDATE, args[0] ) ) else: @@ -493,7 +505,7 @@ def _invoke() -> Any: # Broadcast calls of parameters. self._broadcastParameterChange( - ParameterBroadcastBluePrint(spec.target, "parameter-call", ret) + ParameterBroadcastBluePrint(spec.target, PARAMETER_CALL, ret) ) else: self.funcCalled.emit(spec.target, args, kwargs, ret) @@ -567,6 +579,17 @@ def _getGuiConfig(self, instrumentName: str) -> str: return json.dumps(self.guiConfig[instrumentName]) + def _registerBroadcaster(self, instrument: Any) -> None: + """ + Register the server as a Broadcast sink on an instrument implementing + the Broadcaster contract (ADR-0003). Instruments without the contract + are left untouched. + + :param instrument: The instrument that joined the Station. + """ + if hasattr(instrument, "add_broadcast_sink"): + instrument.add_broadcast_sink(self._broadcastParameterChange) + def _broadcastParameterChange(self, blueprint: ParameterBroadcastBluePrint) -> None: """ Broadcast any changes to parameters in the server. @@ -605,12 +628,15 @@ def _newOrDeleteParameterDetection( if spec.target.split(".")[-1] == "add_parameter": name = spec.target.split(".")[0] + "." + ".".join(spec.args) # type: ignore[arg-type] pb = ParameterBroadcastBluePrint( - name, "parameter-creation", kwargs["initial_value"], kwargs["unit"] + name, + PARAMETER_CREATION, + kwargs.get("initial_value"), + kwargs.get("unit", ""), ) self._broadcastParameterChange(pb) elif spec.target.split(".")[-1] == "remove_parameter": name = spec.target.split(".")[0] + "." + ".".join(spec.args) # type: ignore[arg-type] - pb = ParameterBroadcastBluePrint(name, "parameter-deletion") + pb = ParameterBroadcastBluePrint(name, PARAMETER_DELETION) self._broadcastParameterChange(pb) def _get_lock_for_target(self, target: str) -> Optional[threading.RLock]: diff --git a/src/instrumentserver/testing/dummy_instruments/generic.py b/src/instrumentserver/testing/dummy_instruments/generic.py index aaecad6..c667d70 100644 --- a/src/instrumentserver/testing/dummy_instruments/generic.py +++ b/src/instrumentserver/testing/dummy_instruments/generic.py @@ -11,6 +11,9 @@ from qcodes import Instrument, validators from qcodes.math_utils.field_vector import FieldVector +from instrumentserver.base import Broadcaster +from instrumentserver.blueprints import ParameterBroadcastBluePrint + class StatusFlag(IntFlag): """An ``IntFlag`` mirroring drivers like the Yokogawa GS200 status byte. @@ -434,3 +437,30 @@ def get_status(self): def get_condition(self): return self._condition + + +class DummyBroadcasterInstrument(Broadcaster, Instrument): + """A dummy instrument implementing the Broadcaster contract. + + The Server registers itself as a Broadcast sink when this instrument + joins the Station. Tests call :meth:`emit_broadcast` through a proxy + to make the instrument emit a Broadcast over the Server's PUB socket. + """ + + def __init__(self, name: str, *args, **kwargs): + super().__init__(name, *args, **kwargs) + self.add_parameter("param0", set_cmd=None, initial_value=0) + + def emit_broadcast(self, value=1.0, unit="V", action="parameter-update"): + """ + Emit a Broadcast about ``param0`` and return the blueprint that was sent. + + :param value: The value carried by the Broadcast. + :param unit: The unit carried by the Broadcast. + :param action: The action string carried by the Broadcast. + """ + bp = ParameterBroadcastBluePrint( + name=f"{self.name}.param0", action=action, value=value, unit=unit + ) + self.broadcast(bp) + return bp diff --git a/test/docs_verification/helpers.py b/test/docs_verification/helpers.py index e596080..53be235 100644 --- a/test/docs_verification/helpers.py +++ b/test/docs_verification/helpers.py @@ -20,11 +20,13 @@ import socket import subprocess +import threading import time from contextlib import contextmanager -from typing import Any, Iterator, List, Optional, Sequence +from typing import Any, Iterator, List, Optional, Sequence, Tuple import qcodes as qc +import zmq from instrumentserver import DEFAULT_PORT, QtCore, QtWidgets from instrumentserver.client.core import BaseClient @@ -190,6 +192,73 @@ def wait_for(self, n: int = 1, timeout: float = 5.0) -> List[Any]: return list(self.messages) +class RawFrameCapture: + """Collects the raw two-frame ZMQ messages a PUB socket publishes. + + Use via ``capture_raw_frames``. Each entry is a ``(topic, payload)`` + tuple of ``str``: frame 1 is the topic string the publisher chose, + frame 2 the JSON document of the message. + """ + + def __init__(self) -> None: + self.frames: List[Tuple[str, str]] = [] + + def wait_for(self, n: int = 1, timeout: float = 5.0) -> List[Tuple[str, str]]: + """Block until at least ``n`` frame pairs arrived; return them all.""" + deadline = time.monotonic() + timeout + while len(self.frames) < n: + if time.monotonic() > deadline: + raise TimeoutError( + f"Expected {n} raw frame pair(s) within {timeout}s, " + f"got {len(self.frames)}: {self.frames!r}" + ) + time.sleep(0.05) + return list(self.frames) + + +@contextmanager +def capture_raw_frames( + port: int, host: str = "localhost", topic: str = "" +) -> Iterator[RawFrameCapture]: + """Capture the raw two-frame messages a PUB socket publishes. + + A plain ``zmq.SUB`` socket on its own thread, for sections whose claims + are about the wire format itself (``capture_broadcasts`` decodes in the + SubClient, so the frames never reach the caller). + + :param port: the port the PUB socket is bound to. + :param host: host the PUB socket is bound on. + :param topic: subscription prefix; ``""`` receives every message. + """ + capture = RawFrameCapture() + context = zmq.Context.instance() + sock = context.socket(zmq.SUB) + sock.setsockopt_string(zmq.SUBSCRIBE, topic) + sock.connect(f"tcp://{host}:{port}") + sock.setsockopt(zmq.RCVTIMEO, 100) # ms, so the loop can check the stop flag + stop = threading.Event() + + def _collect() -> None: + while not stop.is_set(): + try: + parts = sock.recv_multipart() + except zmq.Again: + continue + capture.frames.append((parts[0].decode("utf-8"), parts[1].decode("utf-8"))) + + thread = threading.Thread(target=_collect, daemon=True) + thread.start() + # PUB/SUB slow-joiner: give the SUB socket a moment to connect and + # subscribe before the caller triggers the messages it wants to see. + time.sleep(0.3) + try: + yield capture + finally: + stop.set() + thread.join(2) + sock.close(linger=0) + + @contextmanager def capture_broadcasts( instruments: Optional[List[str]] = None, diff --git a/test/docs_verification/technical_guide/verify_broadcasts.py b/test/docs_verification/technical_guide/verify_broadcasts.py new file mode 100644 index 0000000..23bcf7b --- /dev/null +++ b/test/docs_verification/technical_guide/verify_broadcasts.py @@ -0,0 +1,972 @@ +"""Verification script for docs/technical_guide/broadcasts.md. + +One section below per page section, in page order (see +test/docs_verification/README.md for conventions). Asserts every behavioral +claim the page makes; exits 0 on success. + +A Parameter Manager writes its profile files into the working directory of +the process it lives in, so every section that hosts one runs inside a +throwaway working directory created (and removed again) under this script's +folder; no profile file is ever left in the repository. + +The wire-format and external-forwarding claims are observed with raw ZMQ +frames (``capture_raw_frames``); the consumer-side claims go through the +SubClient (``capture_broadcasts``), the intended live-update path. +""" + +import json +import logging +import os +import shutil +import socket +import sys +import tempfile +import threading +import time +from contextlib import contextmanager +from pathlib import Path + +import zmq + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from helpers import ( + DUMMY_INSTRUMENT, + capture_broadcasts, + capture_raw_frames, + client, + ensure_qapp, + server, +) + +from instrumentserver import DEFAULT_PORT, QtCore +from instrumentserver.base import Broadcaster, decode, recvMultipart +from instrumentserver.blueprints import ( + PARAMETER_CALL, + PARAMETER_CREATION, + PARAMETER_DELETION, + PARAMETER_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ParameterBroadcastBluePrint, + PMLockBluePrint, + PMTypeBluePrint, + bluePrintToDict, + deserialize_obj, +) +from instrumentserver.client.proxy import SubClient +from instrumentserver.config import loadConfig +from instrumentserver.gui.parameter_manager import PMState +from instrumentserver.params import ParameterManager + +PM_CLASS = "instrumentserver.params.ParameterManager" +PM_NAME = "parameter_manager" +BROADCASTER_CLASS = ( + "instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument" +) +BROADCAST_PORT = DEFAULT_PORT + 1 + + +@contextmanager +def workspace(): + """Run one section in a throwaway working directory. + + The in-process Server (and with it every Parameter Manager it hosts) + writes its profile files into the working directory. The directory is + created under this script's folder and removed again on exit. + """ + old = os.getcwd() + path = Path( + tempfile.mkdtemp(prefix="verify_broadcasts_", dir=Path(__file__).parent) + ) + os.chdir(path) + try: + yield path + finally: + os.chdir(old) + shutil.rmtree(path, ignore_errors=True) + + +def capture_actions(action, instruments=None): + """Run ``action`` under a Broadcast capture and return every message + the capture saw while ``action`` ran, plus a short settle wait. + + ``wait_for(1)`` returns as soon as the first message lands, so the + settle wait runs before the list is taken: a caller counting the + messages sees the ones belonging to this action only. + """ + with capture_broadcasts(instruments) as cap: + action() + cap.wait_for(1) + time.sleep(0.2) + return list(cap.messages) + + +def capture_nothing(action, instruments=None): + """Run ``action`` under a Broadcast capture and assert that nothing + arrives while it runs and during a settle wait.""" + with capture_broadcasts(instruments) as cap: + action() + time.sleep(0.4) + assert cap.messages == [], cap.messages + + +def free_port() -> int: + """A free TCP port on loopback (bind to 0, read it, close).""" + while True: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + if port not in (DEFAULT_PORT, BROADCAST_PORT): + return port + + +# --------------------------------------------------------------------------- +# Section: What triggers a Broadcast +# +# Page claims: the Server emits from the worker thread that executed the +# client request, under that instrument's instrument mutex; parameter-update +# on a parameter set (with the value set), parameter-call on a parameter get +# (with the value read), parameter-creation / parameter-deletion when the +# called method is literally named add_parameter / remove_parameter (with +# name = ., and for creation the initial_value and +# unit keyword arguments); any other method call emits nothing unless the +# instrument is a Broadcaster; the six action strings are the module +# constants in blueprints.py. +# --------------------------------------------------------------------------- +def section_what_triggers_a_broadcast() -> None: + assert PARAMETER_UPDATE == "parameter-update" + assert PARAMETER_CALL == "parameter-call" + assert PARAMETER_CREATION == "parameter-creation" + assert PARAMETER_DELETION == "parameter-deletion" + assert PM_LOCK_UPDATE == "pm-lock-update" + assert PM_TYPE_UPDATE == "pm-type-update" + + with workspace(), server() as srv: + with client() as cli: + dummy = cli.find_or_create_instrument("dummy", DUMMY_INSTRUMENT) + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + pm.add_parameter("q01.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01Data.IF", initial_value=10e6, unit="Hz") + + # a parameter set: parameter-update, carrying the value set + with capture_broadcasts() as cap: + dummy.param0.set(0.5) + (bp,) = cap.wait_for(1) + assert bp.name == "dummy.param0" + assert bp.action == PARAMETER_UPDATE + assert bp.value == 0.5 + + # a parameter get: parameter-call, carrying the value read + with capture_broadcasts() as cap: + assert dummy.param0() == 0.5 + (bp,) = cap.wait_for(1) + assert bp.name == "dummy.param0" + assert bp.action == PARAMETER_CALL + assert bp.value == 0.5 + + # a direct add_parameter call: parameter-creation, named + # ., with the initial_value and unit + # keyword arguments + with capture_broadcasts([PM_NAME]) as cap: + pm.add_parameter("extra.gain", initial_value=3, unit="V") + (bp,) = cap.wait_for(1) + assert bp.name == "parameter_manager.extra.gain" + assert bp.action == PARAMETER_CREATION + assert bp.value == 3 + assert bp.unit == "V" + + # a direct remove_parameter call: parameter-deletion + with capture_broadcasts([PM_NAME]) as cap: + pm.remove_parameter("extra.gain") + (bp,) = cap.wait_for(1) + assert bp.name == "parameter_manager.extra.gain" + assert bp.action == PARAMETER_DELETION + assert bp.value is None + + # no initial_value / unit given: value None, unit "" + with capture_broadcasts([PM_NAME]) as cap: + pm.add_parameter("extra.offset") + (bp,) = cap.wait_for(1) + assert bp.name == "parameter_manager.extra.offset" + assert bp.action == PARAMETER_CREATION + assert bp.value is None + assert bp.unit == "" + pm.remove_parameter("extra.offset") + + # any other method call emits nothing: a method that is no + # parameter, and read-only Parameter Manager queries + capture_nothing(lambda: dummy.test_func(1, 2, 3, d=4)) + capture_nothing(lambda: pm.list_types()) + capture_nothing(lambda: pm.has_param("q01.IF")) + + # the Server emits from the worker thread that executed the + # request: a sink on the instrument (the Parameter Manager is a + # Broadcaster) sees a ThreadPoolExecutor worker, not the caller. + # The instrument's sinks run for its own emissions (the Lock + # methods); a plain parameter set is emitted by the Server + # itself, not through the instrument + recorded = [] + + def recording_sink(bp) -> None: + recorded.append(threading.current_thread()) + + pm_instrument = srv.station.components[PM_NAME] + pm_instrument.add_broadcast_sink(recording_sink) + try: + with capture_broadcasts([PM_NAME]) as cap: + pm.lock("q01.IF", "q01Data.IF") + cap.wait_for(1) + assert recorded, "the sink never ran" + assert recorded[0] is not threading.current_thread() + assert recorded[0].name.startswith("ThreadPoolExecutor"), recorded[ + 0 + ].name + finally: + pm_instrument.remove_broadcast_sink(recording_sink) + pm.unlock("q01.IF") + + # and the emission happens while the worker holds the + # instrument mutex: a second request for the same instrument + # waits until the first one is done + def blocked_call() -> None: + with client() as second_cli: + finished.append(second_cli.call(f"{PM_NAME}.set", "q01.IF", 12e6)) + + mutex = srv._get_lock_for_target(PM_NAME) + finished = [] + mutex.acquire() + try: + worker = threading.Thread(target=blocked_call, daemon=True) + worker.start() + time.sleep(0.5) + assert finished == [], "the second call was not serialized" + finally: + mutex.release() + worker.join(5) + assert finished != [] + assert pm.q01.IF() == 12000000.0 + print("section_what_triggers_a_broadcast: OK") + + +# --------------------------------------------------------------------------- +# Section: The wire format +# +# Page claims: the PUB socket lives on the request port + 1; a Broadcast is +# a two-frame ZMQ message, frame 1 the topic (the instrument name, the first +# dotted component of the message's name, so subscribers can filter per +# instrument, as a plain prefix match), frame 2 the JSON of the +# ParameterBroadcastBluePrint dict with its _class_type; decode / +# deserialize_obj rebuild the dataclass from that dict; PUB/SUB slow +# joiner: subscribe before you trigger. +# --------------------------------------------------------------------------- +def section_the_wire_format() -> None: + with workspace(), server() as srv: + # the publishing socket is bound on the request port + 1; no + # external forwarding is configured here + assert srv.broadcastPort == BROADCAST_PORT + assert srv.externalBroadcastAddr is None + assert srv.externalBroadcastSocket is None + + with client() as cli: + dummy = cli.find_or_create_instrument("dummy", DUMMY_INSTRUMENT) + + with capture_raw_frames(BROADCAST_PORT) as cap: + dummy.param0.set(0.5) + frames = cap.wait_for(1) + topic, payload = frames[0] + print(f"wire format, frame 1 (topic): {topic!r}") + print(f"wire format, frame 2 (payload): {payload}") + + # frame 1: the topic is the instrument name, the first dotted + # component of the message's name + assert topic == "dummy" + + # frame 2: the JSON of the ParameterBroadcastBluePrint dict, + # with its _class_type; every field is on the wire as a string + assert json.loads(payload) == { + "name": "dummy.param0", + "action": "parameter-update", + "value": "0.5", + "unit": "", + "_class_type": "ParameterBroadcastBluePrint", + } + + # decode / deserialize_obj rebuild the dataclass from that dict + bp = deserialize_obj(json.loads(payload)) + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "dummy.param0" + assert bp.action == PARAMETER_UPDATE + assert bp.value == 0.5 # the numeric strings come back as numbers + assert bp.unit == "" + assert decode(payload) == bp # decode does the loads + deserialize + + # and bluePrintToDict is the exact inverse of that rebuild + assert bluePrintToDict(bp) == json.loads(payload) + + # the topic lets subscribers filter per instrument, as a plain + # prefix match: subscribing to "dummy" also receives "dummy2" + dummy2 = cli.find_or_create_instrument("dummy2", DUMMY_INSTRUMENT) + with capture_raw_frames(BROADCAST_PORT, topic="dummy") as cap: + dummy2.param0.set(1) + frames = cap.wait_for(1) + assert frames[0][0] == "dummy2" + + # PUB/SUB slow joiner: a subscriber that connects after the + # trigger misses the message. Subscribe before you trigger. + dummy.param0.set(0.75) + time.sleep(0.2) # the message is published (and gone for late joiners) + with capture_raw_frames(BROADCAST_PORT) as late: + assert late.frames == [] + dummy.param0.set(0.8) + frames = late.wait_for(1) + assert json.loads(frames[0][1])["value"] == "0.8" + + # one Broadcast reaches every subscriber: two captures on the + # same socket both receive the same two frames + with ( + capture_raw_frames(BROADCAST_PORT) as cap_a, + capture_raw_frames(BROADCAST_PORT) as cap_b, + ): + dummy.param0.set(0.9) + frames_a = cap_a.wait_for(1) + frames_b = cap_b.wait_for(1) + assert frames_a == frames_b + print("section_the_wire_format: OK") + + +# --------------------------------------------------------------------------- +# Section: SubClient +# +# Page claims: the SubClient is the Qt consumer: a SUB socket subscribing to +# named instruments or to all, emitting every Broadcast as a +# ParameterBroadcastBluePrint on its update signal, running on its own +# QThread, stoppable with stop(); GUIs stay live by applying the +# pm-lock-update / pm-type-update payloads to their state locally, with no +# follow-up fetch (D22); a non-Qt consumer runs a plain SUB loop like the +# Listener's. +# --------------------------------------------------------------------------- +def section_subclient() -> None: + with workspace(), server(): + with client() as cli: + dummy = cli.find_or_create_instrument("dummy", DUMMY_INSTRUMENT) + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + pm.add_parameter("q01.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01Data.IF", initial_value=10e6, unit="Hz") + + # the Qt consumer: subscribe to named instruments, run on its + # own QThread, emit ParameterBroadcastBluePrints on update + ensure_qapp() + received = [] + sub = SubClient(instruments=[PM_NAME]) + sub.update.connect(received.append, QtCore.Qt.DirectConnection) + thread = QtCore.QThread() + sub.moveToThread(thread) + thread.started.connect(sub.connect) + sub.finished.connect(thread.quit) + thread.start() + assert sub.thread() is thread + assert thread is not QtCore.QThread.currentThread() + deadline = time.monotonic() + 5 + while not sub.connected and time.monotonic() < deadline: + time.sleep(0.05) + assert sub.connected + time.sleep(0.3) # PUB/SUB slow joiner + + # another instrument's Broadcast is filtered out by the topic + dummy.param0.set(0.5) + pm.q01.IF.set(11e6) + deadline = time.monotonic() + 5 + while len(received) < 1 and time.monotonic() < deadline: + time.sleep(0.05) + assert len(received) == 1, received + assert isinstance(received[0], ParameterBroadcastBluePrint) + assert received[0].name == "parameter_manager.q01.IF" + assert received[0].action == PARAMETER_UPDATE + + # stop() ends the listener loop; the GUI's stopListener stops + # the thread with it: stop(), thread.quit(), thread.wait() + sub.stop() + thread.quit() + assert thread.wait(2000) + assert not thread.isRunning() + + # instruments=None (the default) subscribes to everything + with capture_broadcasts() as cap: # no instrument names: all + dummy.param0.set(0.6) + pm.q01.IF.set(11.5e6) + messages = cap.wait_for(2) + assert {bp.name for bp in messages} == { + "dummy.param0", + "parameter_manager.q01.IF", + } + + # how GUIs stay live (D22): the Parameter Manager GUI keeps a + # PMState, fills it once, and applies every pm-lock-update / + # pm-type-update payload locally; no follow-up fetch. Here the + # payloads of a second Client's changes are applied by hand and + # must equal a fresh full fetch afterwards. + state = PMState() + state.refresh(pm) + assert state.locks == {} + assert state.types == {} + + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") + with capture_broadcasts([PM_NAME]) as cap: + pm.lock("q01.IF", "q01Data.IF") + pm.set_type_parameter_default("qubit", "IF", 12e6) + cap.wait_for(2) + time.sleep(0.2) + for bp in cap.messages: + path = ".".join(bp.name.split(".")[1:]) + if bp.action == PM_LOCK_UPDATE: + state.apply_lock(path, bp.value) + elif bp.action == PM_TYPE_UPDATE: + state.apply_type(path, bp.value) + fresh = PMState() + fresh.refresh(pm) + assert state.locks == fresh.locks + assert state.types == fresh.types + + # a non-Qt consumer: a plain SUB loop like the Listener's, on a + # plain thread, with recvMultipart doing the decode + listener_messages = [] + stop = threading.Event() + + def run_listener() -> None: + context = zmq.Context.instance() + sock = context.socket(zmq.SUB) + sock.connect(f"tcp://localhost:{BROADCAST_PORT}") + sock.setsockopt_string(zmq.SUBSCRIBE, "") + sock.setsockopt(zmq.RCVTIMEO, 100) # ms + try: + while not stop.is_set(): + try: + listener_messages.append(recvMultipart(sock)) + except zmq.Again: + continue + finally: + sock.close(linger=0) + + listener_thread = threading.Thread(target=run_listener, daemon=True) + listener_thread.start() + time.sleep(0.3) # PUB/SUB slow joiner + # the Target accepts set; the locked Follower would refuse it + pm.q01Data.IF.set(13e6) + deadline = time.monotonic() + 5 + while len(listener_messages) < 1 and time.monotonic() < deadline: + time.sleep(0.05) + assert listener_messages, "the plain SUB loop received nothing" + topic, bp = listener_messages[0] + assert topic == PM_NAME + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "parameter_manager.q01Data.IF" + assert bp.value == 13000000.0 + stop.set() + listener_thread.join(2) + print("section_subclient: OK") + + +# --------------------------------------------------------------------------- +# Section: External forwarding +# +# Page claims: the station config's ipAddresses.externalBroadcast (a +# "tcp://address:port" string, the networking section of the config file) +# makes the Server bind a second PUB socket there and send every Broadcast +# to both sockets, with the same two frames, so live UIs and Listeners on +# other machines subscribe unchanged. +# --------------------------------------------------------------------------- +def section_external_forwarding() -> None: + with workspace() as workdir: + # the config file form: the networking section's externalBroadcast + # lands in the ipAddresses dict the Server is started with + config = workdir / "extBroadcastConfig.yml" + config.write_text( + "instruments: {}\n" + "networking:\n" + ' externalBroadcast: "tcp://127.0.0.1:6000"\n' + ) + station_cfg, _server_cfg, _full, _shortcuts, temp_file, _rates, addresses = ( + loadConfig(str(config)) + ) + temp_file.close() + assert addresses == {"externalBroadcast": "tcp://127.0.0.1:6000"} + + # the in-process form: startServer takes ipAddresses directly + external_port = free_port() + with server( + ipAddresses={"externalBroadcast": f"tcp://127.0.0.1:{external_port}"} + ) as srv: + assert srv.externalBroadcastAddr == f"tcp://127.0.0.1:{external_port}" + with client() as cli: + dummy = cli.find_or_create_instrument("dummy", DUMMY_INSTRUMENT) + + # every Broadcast goes to both sockets, as the same two frames + with ( + capture_raw_frames(BROADCAST_PORT) as main_cap, + capture_raw_frames(external_port) as ext_cap, + ): + dummy.param0.set(0.5) + main_frames = main_cap.wait_for(1) + ext_frames = ext_cap.wait_for(1) + assert ext_frames == main_frames + + # so a consumer on the external address works unchanged + with capture_broadcasts(["dummy"], port=external_port) as cap: + dummy.param0.set(0.7) + (bp,) = cap.wait_for(1) + assert bp.name == "dummy.param0" + assert bp.value == 0.7 + print("section_external_forwarding: OK") + + +# --------------------------------------------------------------------------- +# Section: The Broadcaster contract +# +# Page claims: the Broadcaster mixin (add_broadcast_sink / +# remove_broadcast_sink / broadcast) fans a Broadcast out to its sinks; +# the Server registers _broadcastParameterChange as a sink at the two +# registration points (creation over the wire, loading from the station +# config), by hasattr(instrument, "add_broadcast_sink"); instruments +# without it are untouched; standalone use with no sinks is a no-op; a sink +# that raises is logged and the others still run; Broadcaster emissions use +# the same socket, topic and wire format as the Server's own Broadcasts, so +# existing subscribers need no change; a Broadcaster instrument emits +# through the Server to a SubClient. +# --------------------------------------------------------------------------- +def section_the_broadcaster_contract() -> None: + # the mixin itself, no Server involved + with workspace(): + bc = Broadcaster() + bp = ParameterBroadcastBluePrint("bcaster.param0", PARAMETER_UPDATE, 1.0, "V") + + # no sinks: a no-op + bc.broadcast(bp) + + received = [] + bc.add_broadcast_sink(received.append) + # the same sink twice: sinks are a plain list, so it receives + # every Broadcast twice, and a single remove leaves it in once + bc.add_broadcast_sink(received.append) + bc.broadcast(bp) + assert received == [bp, bp] + + bc.remove_broadcast_sink(received.append) + bc.broadcast(bp) + assert received == [bp, bp, bp] + + bc.remove_broadcast_sink(received.append) + bc.broadcast(bp) + assert received == [bp, bp, bp] + + # a sink that raises is logged; the others still run + pm = ParameterManager(name="pm_standalone") + seen, errors = [], [] + handler = logging.Handler() + handler.emit = lambda record: errors.append(record.getMessage()) + logger = logging.getLogger("instrumentserver.base") + logger.addHandler(handler) + try: + + def failing_sink(bp): + raise RuntimeError("sink is broken") + + pm.add_broadcast_sink(failing_sink) + pm.add_broadcast_sink(seen.append) + pm.broadcast(bp) + finally: + logger.removeHandler(handler) + assert seen == [bp] + assert len(errors) == 1 + assert "broadcast sink" in errors[0], errors + + # standalone use: no sinks registered, so the Parameter Manager's + # own emissions are no-ops and everything works without a Server + pm.add_parameter("own.a", initial_value=1) + pm.add_parameter("own.b", initial_value=2) + pm.lock("own.b", "own.a") + assert pm.get_lock("own.b").locked is True + + # registration point 1: an instrument created over the wire + with workspace(), server() as srv: + with client() as cli: + bcaster = cli.find_or_create_instrument("bcaster", BROADCASTER_CLASS) + instrument = srv.station.components["bcaster"] + assert srv._broadcastParameterChange in instrument._broadcast_sinks + + # instruments without the mixin are untouched + cli.find_or_create_instrument("dummy", DUMMY_INSTRUMENT) + assert not hasattr(srv.station.components["dummy"], "add_broadcast_sink") + + # same socket, topic and wire format as the Server's own + # Broadcasts: two frames, topic = instrument name, JSON with + # the _class_type + with capture_raw_frames(BROADCAST_PORT) as cap: + bcaster.emit_broadcast(value=2.5, unit="V") + frames = cap.wait_for(1) + topic, payload = frames[0] + assert topic == "bcaster" + assert json.loads(payload) == { + "name": "bcaster.param0", + "action": PARAMETER_UPDATE, + "value": "2.5", + "unit": "V", + "_class_type": "ParameterBroadcastBluePrint", + } + + # the minimal example: the Broadcaster instrument emits through + # the Server, and a SubClient receives the blueprint unchanged + with capture_broadcasts(["bcaster"]) as cap: + bcaster.emit_broadcast(value=3.0, unit="V") + (bp,) = cap.wait_for(1) + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "bcaster.param0" + assert bp.action == PARAMETER_UPDATE + assert bp.value == 3.0 + assert bp.unit == "V" + + # registration point 2: an instrument loaded from the station config + with workspace() as workdir: + config = workdir / "broadcasterConfig.yml" + config.write_text( + "instruments:\n" + " cfg_bcaster:\n" + " type: instrumentserver.testing.dummy_instruments.generic." + "DummyBroadcasterInstrument\n" + " initialize: True\n" + ) + station_cfg, server_cfg, _full, _shortcuts, temp_file, _rates, _addresses = ( + loadConfig(str(config)) + ) + try: + with server(config=station_cfg, serverConfig=server_cfg) as srv: + assert "cfg_bcaster" in srv.station.components + component = srv.station.components["cfg_bcaster"] + assert srv._broadcastParameterChange in component._broadcast_sinks + finally: + temp_file.close() + print("section_the_broadcaster_contract: OK") + + +# --------------------------------------------------------------------------- +# Section: The Parameter Manager's actions +# +# Page claims: pm-lock-update, one per affected Follower, name = full +# Follower path, value = PMLockBluePrint(target, locked) or None when the +# Lock was removed, emitted by every Lock method and by remove_parameter +# when deleting a Target drops Locks (D10); pm-type-update, name = +# ., value = PMTypeBluePrint(name, parameters, nested, +# effective) or None when the Type was removed, from every Type-editing +# method (add_type, add_type_parameter, set_type_parameter_default, +# set_type_parameter_unit, remove_type_parameter, add_nested_type, +# remove_nested_type, remove_type, and the Type Lock methods +# lock_type_parameter / unlock_type_parameter, D22), including the +# documented order of add_nested_type; re-emitted parameter-creation for +# parameters the Parameter Manager creates as side effects, while direct +# add_parameter / remove_parameter calls are announced by the Server, so +# nothing is announced twice; the two payload blueprints pinned field by +# field as they appear on the wire; the ordering rules the docstrings +# state. +# --------------------------------------------------------------------------- +def section_the_parameter_managers_actions() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + pm.add_parameter("q01Data.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01.IF", initial_value=5e6, unit="Hz") + + # pm-lock-update: one per affected Follower, name = full + # Follower path, value = PMLockBluePrint(target, locked) + with capture_broadcasts([PM_NAME]) as cap: + pm.lock("q01.IF", "q01Data.IF") + (bp,) = cap.wait_for(1) + assert bp.action == PM_LOCK_UPDATE + assert bp.name == "parameter_manager.q01.IF" + assert bp.value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + # every Lock method announces its state change exactly once + messages = capture_actions(lambda: pm.unlock("q01.IF")) + assert len(messages) == 1 + assert messages[0].value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + messages = capture_actions(lambda: pm.relock("q01.IF")) + assert len(messages) == 1 and messages[0].value.locked is True + messages = capture_actions(lambda: pm.toggle_lock("q01.IF")) + assert len(messages) == 1 and messages[0].value.locked is False + messages = capture_actions(lambda: pm.toggle_lock("q01.IF")) + assert len(messages) == 1 and messages[0].value.locked is True + + # the no-op path emits nothing: unlocking an unlocked Lock + pm.unlock("q01.IF") + capture_nothing(lambda: pm.unlock("q01.IF"), [PM_NAME]) + pm.relock("q01.IF") + + # remove_lock announces the removal with a None payload + messages = capture_actions(lambda: pm.remove_lock("q01.IF")) + assert len(messages) == 1 + assert messages[0].action == PM_LOCK_UPDATE + assert messages[0].name == "parameter_manager.q01.IF" + assert messages[0].value is None + pm.lock("q01.IF", "q01Data.IF") + + # deleting a Target: one pm-lock-update with None per dropped + # Follower, then the Server's parameter-deletion (the deletion + # itself emits nothing in the Parameter Manager) + messages = capture_actions(lambda: pm.remove_parameter("q01Data.IF")) + actions = [bp.action for bp in messages] + assert actions == [PM_LOCK_UPDATE, PARAMETER_DELETION], actions + assert messages[0].name == "parameter_manager.q01.IF" + assert messages[0].value is None + assert messages[1].name == "parameter_manager.q01Data.IF" + + # pm-type-update: name = ., value = the fresh + # PMTypeBluePrint, one per Type edit. (The Type's entry is named + # gain, not IF: the lock-demo submodule q01 carries q01.IF in + # Hz, and a Type requiring IF would duck-type it into an + # Instance and broadcast about it too.) + messages = capture_actions(lambda: pm.add_type("qubit")) + assert len(messages) == 1 + assert messages[0].action == PM_TYPE_UPDATE + assert messages[0].name == "parameter_manager.qubit" + assert messages[0].value == PMTypeBluePrint( + name="qubit", parameters={}, nested={}, effective={} + ) + + # no Instances yet, so an entry edit emits only the Type update + messages = capture_actions( + lambda: pm.add_type_parameter("qubit", "gain", default=10, unit="dB") + ) + assert [bp.action for bp in messages] == [PM_TYPE_UPDATE] + + # an Instance exists: the entry written into it is announced as + # a parameter-creation first, then the Type update + pm.add_instance("qubit", "q02") + pm.update() + messages = capture_actions( + lambda: pm.add_type_parameter("qubit", "window", default=0.5, unit="s") + ) + assert [bp.action for bp in messages] == [ + PARAMETER_CREATION, + PM_TYPE_UPDATE, + ], [(bp.action, bp.name, bp.value, bp.unit) for bp in messages] + assert messages[0].name == "parameter_manager.q02.window" + assert messages[0].value == 0.5 + assert messages[0].unit == "s" + assert messages[1].name == "parameter_manager.qubit" + assert messages[1].value.name == "qubit" + assert messages[1].value.parameters["window"] == { + "default": 0.5, + "unit": "s", + "target": None, + } + + # add_instance announces every parameter it creates, in + # creation order, and no pm-type-update: it edits no Type + messages = capture_actions(lambda: pm.add_instance("qubit", "q03")) + actions = [bp.action for bp in messages] + assert actions == [PARAMETER_CREATION, PARAMETER_CREATION], actions + assert [bp.name for bp in messages] == [ + "parameter_manager.q03.gain", + "parameter_manager.q03.window", + ] + + # the remaining Type-editing methods announce their Type too: + # exactly one pm-type-update each (no Types nest them here) + messages = capture_actions( + lambda: pm.set_type_parameter_unit("qubit", "gain", "V") + ) + assert [bp.action for bp in messages] == [PM_TYPE_UPDATE] + assert messages[0].name == "parameter_manager.qubit" + + messages = capture_actions( + lambda: pm.remove_type_parameter("qubit", "window") + ) + assert [bp.action for bp in messages] == [PM_TYPE_UPDATE] + assert messages[0].name == "parameter_manager.qubit" + + # add_nested_type, on a Type with one Instance and a Nested + # Type carrying a Type Lock: one parameter-creation per + # created parameter, in creation order, then the pm-lock-updates + # of the Type Locks the new Instances get, then one + # pm-type-update per affected Type, the edited Type first + pm.add_type("pulse") + pm.add_type_parameter("pulse", "length", default=0.5, unit="s") + pm.add_instance("pulse", "p01") + pm.lock_type_parameter("pulse", "length") + pm.add_type("qubitline") + pm.add_type_parameter("qubitline", "drive", default=1.0, unit="dBm") + pm.add_instance("qubitline", "ql01") + pm.update() + messages = capture_actions( + lambda: pm.add_nested_type("qubitline", "readout", "pulse") + ) + actions = [bp.action for bp in messages] + assert actions == [ + PARAMETER_CREATION, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ], actions + assert messages[0].name == "parameter_manager.ql01.readout.length" + assert messages[0].value == 0.5 + assert messages[0].unit == "s" + assert messages[1].name == "parameter_manager.ql01.readout.length" + assert messages[1].value == PMLockBluePrint( + target="parameter_manager._globals.pulse.length", locked=True + ) + assert messages[2].name == "parameter_manager.qubitline" + + # removing the Nested Type requirement announces the Type once + messages = capture_actions( + lambda: pm.remove_nested_type("qubitline", "readout") + ) + assert [bp.action for bp in messages] == [PM_TYPE_UPDATE] + assert messages[0].name == "parameter_manager.qubitline" + assert messages[0].value.nested == {} + + # remove_type announces the removal with a None payload + pm.add_type("shortlived") + messages = capture_actions(lambda: pm.remove_type("shortlived")) + assert len(messages) == 1 + assert messages[0].action == PM_TYPE_UPDATE + assert messages[0].name == "parameter_manager.shortlived" + assert messages[0].value is None + + # a Type Lock with the default Globals Target: the created + # Globals parameter first, then one pm-lock-update per applied + # Lock, then the pm-type-update of the edited Type + pm.add_type("readout") + pm.add_type_parameter("readout", "bw", default=20e6, unit="Hz") + pm.add_instance("readout", "r01") + pm.add_instance("readout", "r02") + messages = capture_actions(lambda: pm.lock_type_parameter("readout", "bw")) + actions = [bp.action for bp in messages] + assert actions == [ + PARAMETER_CREATION, + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ], actions + assert messages[0].name == "parameter_manager._globals.readout.bw" + assert messages[0].value == 20000000.0 + assert {bp.name for bp in messages[1:3]} == { + "parameter_manager.r01.bw", + "parameter_manager.r02.bw", + } + assert all(bp.value.locked for bp in messages[1:3]) + assert messages[3].name == "parameter_manager.readout" + + # unlock_type_parameter: exactly one pm-type-update, the Locks stay + messages = capture_actions( + lambda: pm.unlock_type_parameter("readout", "bw") + ) + assert [bp.action for bp in messages] == [PM_TYPE_UPDATE] + assert messages[0].value.parameters["bw"]["target"] is None + + # a new Instance of a Type with a Type Lock: the creation, then + # the Lock the new Instance gets at creation; no Type update + pm.lock_type_parameter("readout", "bw") + messages = capture_actions(lambda: pm.add_instance("readout", "r03")) + actions = [bp.action for bp in messages] + assert actions == [PARAMETER_CREATION, PM_LOCK_UPDATE], actions + assert messages[0].name == "parameter_manager.r03.bw" + assert messages[1].name == "parameter_manager.r03.bw" + assert messages[1].value == PMLockBluePrint( + target="parameter_manager._globals.readout.bw", locked=True + ) + + # a direct add_parameter is announced by the Server, exactly + # once: the Parameter Manager adds nothing of its own + messages = capture_actions( + lambda: pm.add_parameter("q01.power", initial_value=-10, unit="dBm"), + [PM_NAME], + ) + assert len(messages) == 1 + assert messages[0].action == PARAMETER_CREATION + assert messages[0].name == "parameter_manager.q01.power" + + # ... and a direct remove_parameter is announced once, too + messages = capture_actions( + lambda: pm.remove_parameter("q01.power"), [PM_NAME] + ) + assert len(messages) == 1 + assert messages[0].action == PARAMETER_DELETION + + # a Target that a Type Lock rule points at: the dropped Lock + # (pm-lock-update with None) first, then the pm-type-update of + # the cleared rule, then the Server's parameter-deletion + pm.add_parameter("shared.frequency", initial_value=1e6, unit="Hz") + pm.add_type("receiver") + pm.add_type_parameter("receiver", "lo", default=1e6, unit="Hz") + pm.add_instance("receiver", "rcv01") + pm.lock_type_parameter("receiver", "lo", target="shared.frequency") + messages = capture_actions(lambda: pm.remove_parameter("shared.frequency")) + actions = [bp.action for bp in messages] + assert actions == [ + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + PARAMETER_DELETION, + ], actions + assert messages[0].name == "parameter_manager.rcv01.lo" + assert messages[0].value is None + assert messages[1].name == "parameter_manager.receiver" + assert messages[1].value.parameters["lo"]["target"] is None + assert messages[2].name == "parameter_manager.shared.frequency" + + # the two payload blueprints as they appear on the wire, + # pinned field by field (these are the page's two examples) + pm.add_parameter("show.target", initial_value=1.0, unit="V") + pm.add_parameter("show.follower", initial_value=0.0, unit="V") + with capture_raw_frames(BROADCAST_PORT, topic=PM_NAME) as cap: + pm.lock("show.follower", "show.target") + frames = cap.wait_for(1) + lock_payload = frames[0][1] + print(f"pm-lock-update payload: {lock_payload}") + assert json.loads(lock_payload) == { + "name": "parameter_manager.show.follower", + "action": PM_LOCK_UPDATE, + "value": { + "target": "parameter_manager.show.target", + "locked": "True", + "_class_type": "PMLockBluePrint", + }, + "unit": "", + "_class_type": "ParameterBroadcastBluePrint", + } + + pm.add_type("display") + pm.add_type_parameter("display", "gain", default=10, unit="dB") + with capture_raw_frames(BROADCAST_PORT, topic=PM_NAME) as cap: + pm.set_type_parameter_default("display", "gain", 12) + frames = cap.wait_for(1) + type_payload = frames[0][1] + print(f"pm-type-update payload: {type_payload}") + assert json.loads(type_payload) == { + "name": "parameter_manager.display", + "action": PM_TYPE_UPDATE, + "value": { + "name": "display", + "parameters": { + "gain": {"default": "12", "unit": "dB", "target": "None"} + }, + "nested": {}, + "effective": {"gain": {"unit": "dB", "from_type": "display"}}, + "_class_type": "PMTypeBluePrint", + }, + "unit": "", + "_class_type": "ParameterBroadcastBluePrint", + } + print("section_the_parameter_managers_actions: OK") + + +if __name__ == "__main__": + section_what_triggers_a_broadcast() + section_the_wire_format() + section_subclient() + section_external_forwarding() + section_the_broadcaster_contract() + section_the_parameter_managers_actions() + print("verify_broadcasts: all sections OK") diff --git a/test/docs_verification/user_guide/verify_parameter_manager.py b/test/docs_verification/user_guide/verify_parameter_manager.py new file mode 100644 index 0000000..31b7c6d --- /dev/null +++ b/test/docs_verification/user_guide/verify_parameter_manager.py @@ -0,0 +1,808 @@ +"""Verification script for docs/user_guide/parameter_manager.md. + +One section below per page section, in page order (see +test/docs_verification/README.md for conventions). Asserts every behavioral +claim the page makes; exits 0 on success. + +A Parameter Manager writes its profile files into the working directory of +the process it lives in, so every section runs inside a throwaway working +directory created (and removed again) under this script's folder; no profile +file is ever left in the repository. + +GUI claims (the tab layout, tints and gutter bands, the Lock column and lock +button, the context menu, the arm strip flow, the Locks panel, the Types tab +panes, and the delete-Target confirmation dialog) cannot be asserted from a +script. They are verified manually (the plan's task 5.6 records the +end-to-end GUI check) and captured in the page's screenshots; the same goes +for the Server window showing the generic instrument widget unless the +station config's ``gui`` entry names the Parameter Manager widget. What the +GUI section states about keyboard shortcuts is asserted here against the +GUI's shortcut registry. +""" + +import json +import logging +import os +import shutil +import sys +import tempfile +import time +from contextlib import contextmanager +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from helpers import capture_broadcasts, client, server + +from instrumentserver.blueprints import PMLockBluePrint + +PM_CLASS = "instrumentserver.params.ParameterManager" +PM_NAME = "parameter_manager" + + +@contextmanager +def workspace(): + """Run one section in a throwaway working directory. + + The in-process Server (and with it every Parameter Manager it hosts) + writes its profile files into the working directory. The directory is + created under this script's folder and removed again on exit. + """ + old = os.getcwd() + path = Path(tempfile.mkdtemp(prefix="verify_pm_", dir=Path(__file__).parent)) + os.chdir(path) + try: + yield path + finally: + os.chdir(old) + shutil.rmtree(path, ignore_errors=True) + + +def capture_one_broadcast(action): + """Run ``action`` under a Broadcast capture and return every message + the capture saw while ``action`` ran, plus a short settle wait. + + ``wait_for(1)`` returns as soon as the first message lands, so the + settle wait runs before the list is taken: a caller counting the + messages sees the ones belonging to this action only. + """ + with capture_broadcasts([PM_NAME]) as cap: + action() + cap.wait_for(1) + time.sleep(0.2) + return list(cap.messages) + + +# --------------------------------------------------------------------------- +# Section: Concept +# +# Page claims: the Parameter Manager is the flagship Virtual Instrument, the +# single source of truth for experiment parameters; every Client sees the +# same values without refreshing; a change reaches every GUI live through a +# Broadcast. +# --------------------------------------------------------------------------- +def section_concept() -> None: + with workspace(), server(): + with client() as cli_a, client() as cli_b: + pm_a = cli_a.find_or_create_instrument(PM_NAME, PM_CLASS) + pm_a.add_parameter("q01.IF", initial_value=10e6, unit="Hz") + assert pm_a.q01.IF() == 10000000.0 + + # a second Client reads the same value: one source of truth + pm_b = cli_b.get_instrument(PM_NAME) + assert pm_b.q01.IF() == 10000000.0 + + # the change goes out as a Broadcast (which is what makes GUIs + # follow along live, without polling) + with capture_broadcasts([PM_NAME]) as cap: + pm_a.q01.IF.set(11e6) + messages = cap.wait_for(1) + text = repr(messages) + assert "parameter-update" in text, text + assert "parameter_manager.q01.IF" in text, text + + # and the second Client sees the new value on its next read + assert pm_b.q01.IF() == 11000000.0 + print("section_concept: OK") + + +# --------------------------------------------------------------------------- +# Section: Hierarchical parameters +# +# Page claims: dotted paths address parameters in nested Parameter Groups, +# which are created on demand; add_parameter / remove_parameter / list / +# has_param manage the tree; get and set work through Proxy attribute +# access; the default remove_parameter prunes the Parameter Group it +# empties; cleanup=False keeps it, and remove_empty_submodules prunes it. +# --------------------------------------------------------------------------- +def section_hierarchical_parameters() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + + # dotted names create the Parameter Groups on the way + pm.add_parameter("q01.readout.IF", initial_value=20e6, unit="Hz") + pm.add_parameter("q01.power", initial_value=-10, unit="dBm") + assert pm.list() == ["q01.readout.IF", "q01.power"] + assert pm.has_param("q01.readout.IF") + assert not pm.has_param("q01.readout.bw") + + # the Proxy reflects the hierarchy: attribute access reaches the + # nested parameters + assert pm.q01.readout.IF() == 20000000.0 + pm.q01.power.set(-5) + assert pm.q01.power() == -5 + pm.q01.readout.IF.set(21e6) + assert pm.q01.readout.IF() == 21000000.0 + assert pm.q01.readout.IF.unit == "Hz" + + # the default removes the parameter and prunes the Parameter + # Group it empties + pm.remove_parameter("q01.readout.IF") + assert pm.list() == ["q01.power"] + try: + pm.q01.readout + pruned = False + except AttributeError: + pruned = True + assert pruned + + # cleanup=False keeps the emptied Parameter Group instead ... + pm.add_parameter("q01.readout.IF", initial_value=1, unit="Hz") + pm.remove_parameter("q01.readout.IF", cleanup=False) + pm.update() + assert not pm.has_param("q01.readout.IF") + assert pm.q01.readout is not None + + # ... and remove_empty_submodules prunes every empty group + pm.remove_empty_submodules() + pm.update() + try: + pm.q01.readout + pruned = False + except AttributeError: + pruned = True + assert pruned + assert pm.list() == ["q01.power"] + print("section_hierarchical_parameters: OK") + + +# --------------------------------------------------------------------------- +# Section: Types +# +# Page claims: a Type is a named shape (relative paths with defaults and +# units, plus Nested Types); Instances are duck-typed, recomputed on demand, +# never stored; add_instance writes the shape into a new Parameter Group and +# keeps parameters that exist at a target path already; a wrong-unit +# submodule is no Instance; add_type_parameter writes the entry into every +# Instance lacking it; a Type edit emits one pm-type-update Broadcast; +# set_type_parameter_default only affects Instances created later; +# set_type_parameter_unit propagates to every Instance; Nested Types expand +# under their submodule; instances_of / types_of answer on demand; removing +# an entry, a Nested Type or a Type leaves the parameters alone; a Type +# still nested in another refuses to be removed, naming the nester. +# --------------------------------------------------------------------------- +def section_types() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + + # a fresh Type has no Instances: nothing carries its shape yet + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + assert pm.instances_of("qubit") == [] + + # add_instance writes the shape into a new Parameter Group + pm.add_instance("qubit", "q01") + pm.add_instance("qubit", "q02") + assert pm.instances_of("qubit") == ["q01", "q02"] + pm.update() + assert pm.q01.IF() == 10000000.0 + assert pm.q01.octave_gain() == 10 + assert pm.q01.IF.unit == "Hz" + + # duck-typed: a submodule that carries the shape is an Instance, + # no matter how it got its parameters + pm.add_parameter("q03.IF", initial_value=99e6, unit="Hz") + pm.add_parameter("q03.octave_gain", initial_value=1, unit="dB") + assert pm.instances_of("qubit") == ["q01", "q02", "q03"] + assert pm.types_of("q03.IF") == ["qubit"] + + # the unit is part of the shape: a submodule with the wrong unit + # is no Instance + pm.add_parameter("q05.IF", initial_value=1, unit="V") + pm.add_parameter("q05.octave_gain", initial_value=1, unit="dB") + assert "q05" not in pm.instances_of("qubit") + + # a new entry is written into every Instance lacking it + pm.add_type_parameter("qubit", "window", default=0.5, unit="s") + pm.update() + assert pm.q01.window() == 0.5 + assert pm.q03.window() == 0.5 + + # a Type edit announces itself: one pm-type-update Broadcast + messages = capture_one_broadcast( + lambda: pm.set_type_parameter_default("qubit", "window", 1.0) + ) + text = repr(messages) + assert text.count("pm-type-update") == 1, text + assert "parameter_manager.qubit" in text, text + + # a new default only affects Instances created later + assert pm.q01.window() == 0.5 + pm.add_instance("qubit", "q04") + pm.update() + assert pm.q04.window() == 1.0 + + # a new unit propagates to that parameter in every Instance + pm.set_type_parameter_unit("qubit", "IF", "V") + with client() as cli_fresh: + fresh = cli_fresh.get_instrument(PM_NAME) + assert fresh.q01.IF.unit == "V" + assert fresh.q03.IF.unit == "V" + + # a Nested Type expands under its submodule, in the effective set + pm.add_type("readout") + pm.add_type_parameter("readout", "bw", default=20e6, unit="Hz") + pm.add_nested_type("qubit", "readout", "readout") + pm.update() + assert pm.q01.readout.bw() == 20000000.0 + blueprint = pm.get_type("qubit") + assert blueprint.nested == {"readout": "readout"} + assert blueprint.effective["readout.bw"] == { + "unit": "Hz", + "from_type": "readout", + } + # the Nested Type claims its entries; the innermost Type wins + assert pm.types_of("q01.readout.bw") == ["readout", "qubit"] + assert pm.instances_of("readout") == [ + "q01.readout", + "q02.readout", + "q03.readout", + "q04.readout", + ] + + # a Type still required as a Nested Type refuses to be removed, + # naming the Types that nest it + try: + pm.remove_type("readout") + nested_error = None + except Exception as exc: # noqa: BLE001 + nested_error = str(exc) + assert nested_error is not None + assert "qubit" in nested_error, nested_error + + # removing an entry from the Type leaves the parameters alone, + # and the Instances keep matching: the required shape only shrank + pm.remove_type_parameter("qubit", "window") + assert pm.has_param("q01.window") + assert pm.has_param("q04.window") + assert pm.instances_of("qubit") == ["q01", "q02", "q03", "q04"] + + # duck-typing cuts both ways: deleting a required parameter makes + # the submodule stop matching, and nothing else changes + pm.remove_parameter("q02.IF") + assert pm.has_param("q02.octave_gain") + assert pm.instances_of("qubit") == ["q01", "q03", "q04"] + + # same for a Nested Type and for the Type itself: the parameters + # stay whatever happens to the Type + pm.remove_nested_type("qubit", "readout") + assert pm.has_param("q01.readout.bw") + assert "q01.readout" in pm.instances_of("readout") + pm.remove_type("qubit") + assert pm.list_types() == ["readout"] + assert pm.has_param("q01.IF") + assert pm.q01.IF() == 10000000.0 + + # add_instance keeps parameters that exist at a target path + # already, with their own value and unit + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") + pm.add_parameter("q09.IF", initial_value=99, unit="Hz") + pm.add_instance("qubit", "q09") + pm.update() + assert pm.q09.IF() == 99 + assert pm.q09.IF.unit == "Hz" + assert pm.instances_of("qubit") == ["q09"] + print("section_types: OK") + + +# --------------------------------------------------------------------------- +# Section: Locks +# +# Page claims: a Lock has three states (no Lock; Lock present but unlocked, +# remembering its Target; Lock present and locked); a locked Follower +# answers get with the Target's value and refuses set with an error naming +# the Target; values are pulled on get, so setting the Target is enough and +# unlocking exposes the Follower's own value again; Locks chain and each hop +# reads by its own state; cycles and self-locks are refused with an error +# listing the chain; the Target lives in the same Parameter Manager (D8); +# get_lock / list_locks / followers_of report the state; deleting a Target +# removes the Locks that pointed at it; every Lock method the page names +# emits one pm-lock-update Broadcast per affected Follower. +# --------------------------------------------------------------------------- +def section_locks() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + pm.add_parameter("q01Data.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01.IF", initial_value=5e6, unit="Hz") + pm.add_parameter("q02.IF", initial_value=0, unit="Hz") + + with capture_broadcasts([PM_NAME]) as cap: + pm.lock("q01.IF", "q01Data.IF") + messages = cap.wait_for(1) + text = repr(messages) + assert "pm-lock-update" in text, text + assert "parameter_manager.q01.IF" in text, text + + # locked: the Follower answers get with the Target's value + lock = pm.get_lock("q01.IF") + assert lock.target == "parameter_manager.q01Data.IF" + assert lock.locked + assert pm.q01.IF() == 10000000.0 + + # pull on get: setting the Target is enough, nothing is pushed + pm.q01Data.IF.set(11e6) + assert pm.q01.IF() == 11000000.0 + + # set on a locked Follower raises, naming Follower and Target + try: + pm.q01.IF.set(12e6) + set_error = None + except Exception as exc: # noqa: BLE001 + set_error = str(exc) + assert set_error is not None + assert ( + "parameter_manager.q01.IF is locked to " + "parameter_manager.q01Data.IF" in set_error + ), set_error + + # unlocking exposes the Follower's own value again + pm.unlock("q01.IF") + assert pm.get_lock("q01.IF").locked is False + assert pm.q01.IF() == 5000000.0 + pm.relock("q01.IF") + assert pm.q01.IF() == 11000000.0 + + # every Lock method the page names announces the Follower: one + # pm-lock-update per state change + messages = capture_one_broadcast(lambda: pm.toggle_lock("q01.IF")) + text = repr(messages) + assert text.count("pm-lock-update") == 1, text + assert "parameter_manager.q01.IF" in text, text + assert pm.get_lock("q01.IF").locked is False + messages = capture_one_broadcast(lambda: pm.toggle_lock("q01.IF")) + assert pm.get_lock("q01.IF").locked is True + + # bookkeeping: followers_of and list_locks + assert pm.followers_of("q01Data.IF") == ["q01.IF"] + assert list(pm.list_locks()) == ["q01.IF"] + + # chains: each hop reads according to its own state + pm.lock("q02.IF", "q01.IF") + assert pm.q02.IF() == 11000000.0 + pm.q01Data.IF.set(12e6) + assert pm.q02.IF() == 12000000.0 + pm.unlock("q01.IF") + assert pm.q01.IF() == 5000000.0 + assert pm.q02.IF() == 5000000.0 + pm.relock("q01.IF") + assert pm.q02.IF() == 12000000.0 + assert sorted(pm.list_locks()) == ["q01.IF", "q02.IF"] + + # cycles are refused, with the whole chain in the error + try: + pm.lock("q01Data.IF", "q02.IF") + cycle_error = None + except Exception as exc: # noqa: BLE001 + cycle_error = str(exc) + assert cycle_error is not None + assert "cycle in Lock targets" in cycle_error, cycle_error + for path in ( + "parameter_manager.q02.IF", + "parameter_manager.q01.IF", + "parameter_manager.q01Data.IF", + ): + assert path in cycle_error, cycle_error + + # a self-lock is refused too + try: + pm.lock("q01.IF", "q01.IF") + self_error = None + except Exception as exc: # noqa: BLE001 + self_error = str(exc) + assert self_error is not None + assert "cannot lock" in self_error, self_error + + # the Target must live in the same Parameter Manager (D8) + other = cli.find_or_create_instrument("parameter_manager_2", PM_CLASS) + other.add_parameter("elsewhere.x", initial_value=1) + try: + pm.lock("q01.IF", "parameter_manager_2.elsewhere.x") + cross_error = None + except Exception as exc: # noqa: BLE001 + cross_error = str(exc) + assert cross_error is not None + assert "does not exist" in cross_error, cross_error + + # deleting a Target removes the Locks that pointed at it; the + # Followers become plain parameters + pm.remove_parameter("q01Data.IF") + assert pm.get_lock("q01.IF") is None + assert pm.q01.IF() == 5000000.0 + # a Follower of a survivor keeps its Lock: q02.IF is locked to + # q01.IF, which still exists + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager.q01.IF", locked=True + ) + + # removing a Lock announces it with a None payload + messages = capture_one_broadcast(lambda: pm.remove_lock("q02.IF")) + text = repr(messages) + assert text.count("pm-lock-update") == 1, text + assert "parameter_manager.q02.IF" in text, text + assert pm.list_locks() == {} + print("section_locks: OK") + + +# --------------------------------------------------------------------------- +# Section: Type Locks and Globals +# +# Page claims: lock_type_parameter puts a locked Lock on the entry's +# parameter in every current Instance; with no explicit Target the Target is +# the Globals parameter _globals.., created on demand with the +# entry's default and unit; setting the Globals parameter moves every +# Follower; on a Proxy, pm.get/pm.set are QCoDeS' local shorthands and raise +# KeyError on a dotted path, attribute access reaches the Globals submodule +# once the Proxy knows it (one update() for a Proxy built before it +# existed), and the Parameter Manager's own dotted get/set run through +# Client.call; Instance parameters already locked to another Target are +# skipped, returned and logged; unlock_type_parameter removes only the rule, +# the Locks it created stay; new Instances created after the rule is removed +# get no Lock; Globals is never an Instance, its parameters cannot be +# created through add_parameter, and a Globals parameter is saved with the +# profile. +# --------------------------------------------------------------------------- +def section_type_locks_and_globals() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") + pm.add_instance("qubit", "q01") + pm.add_instance("qubit", "q02") + pm.update() + + # the default Target: the Globals parameter, created on demand + assert pm.lock_type_parameter("qubit", "IF") == [] + assert pm.has_param("_globals.qubit.IF") + assert "_globals.qubit.IF" in pm.list() + + # this Proxy was built before the Globals parameter existed, so + # its cached Blueprint does not know the submodule yet + try: + pm._globals + stale_error = None + except AttributeError as exc: # noqa: BLE001 + stale_error = str(exc) + assert stale_error is not None + + # one update() later, attribute access reaches it + pm.update() + assert pm._globals.qubit.IF() == 10000000.0 + + # the Proxy's own dotted get and set are QCoDeS' local, + # deprecated shorthands: they raise KeyError on a dotted path + try: + pm.get("_globals.qubit.IF") + proxy_get_error = None + except KeyError as exc: # noqa: BLE001 + proxy_get_error = str(exc) + assert proxy_get_error is not None + + # the Parameter Manager's own dotted get and set are reachable + # through Client.call + assert cli.call("parameter_manager.get", "_globals.qubit.IF") == 10000000.0 + for instance in ("q01", "q02"): + lock = pm.get_lock(f"{instance}.IF") + assert lock.target == "parameter_manager._globals.qubit.IF" + assert lock.locked + # Globals is never an Instance + assert pm.instances_of("qubit") == ["q01", "q02"] + + # setting the Globals parameter moves every Follower + cli.call("parameter_manager.set", "_globals.qubit.IF", 12e6) + assert pm._globals.qubit.IF() == 12000000.0 + assert pm.q01.IF() == 12000000.0 + assert pm.q02.IF() == 12000000.0 + + # an explicit Target; an Instance already locked to another + # Target is skipped, returned and logged + pm.add_parameter("lo.frequency", initial_value=1e6, unit="Hz") + pm.remove_lock("q02.IF") + records = [] + handler = logging.Handler() + handler.emit = lambda record: records.append(record.getMessage()) + logger = logging.getLogger("instrumentserver.params") + logger.addHandler(handler) + try: + skipped = pm.lock_type_parameter("qubit", "IF", target="lo.frequency") + finally: + logger.removeHandler(handler) + assert skipped == ["q01.IF"], skipped + assert any("skipped" in message for message in records), records + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager.lo.frequency", locked=True + ) + assert pm.q02.IF() == 1000000.0 + assert pm.q01.IF() == 12000000.0 + + # removing the Type Lock removes only the rule + pm.unlock_type_parameter("qubit", "IF") + assert pm.get_type("qubit").parameters["IF"]["target"] is None + assert pm.get_lock("q02.IF") is not None + # ... so Instances created afterwards get no Lock + pm.add_instance("qubit", "q03") + assert pm.get_lock("q03.IF") is None + + # Globals parameters are created on demand by the Type Lock, not + # through the public API + try: + pm.add_parameter("_globals.extra", initial_value=1) + globals_error = None + except Exception as exc: # noqa: BLE001 + globals_error = str(exc) + assert globals_error is not None + assert "reserved" in globals_error, globals_error + + # a Globals parameter is saved with the profile + pm.toFile() + document = json.loads( + (Path.cwd() / "parameter_manager-parameter_manager.json").read_text() + ) + assert "parameter_manager._globals.qubit.IF" in document["parameters"] + print("section_type_locks_and_globals: OK") + + +# --------------------------------------------------------------------------- +# Section: Profiles and files +# +# Page claims: toFile writes the version-2 profile document (values are the +# parameters' own values, lock appears only on Followers); fromFile loads it +# again, with deleteMissing removing parameters the document does not list; +# the dictionary variant takes deleteMissing explicitly; switch_to_profile +# saves the current profile, then clears everything, then loads the new one; +# sorted(pm.refresh_profiles()) reports the profile files of the working +# directory; a file without a version key is the legacy flat map: it loads +# as parameters only, removes the parameters it does not list (their Locks +# with them), leaves Locks whose parameters stay untouched, and writes no +# Types and no Locks; saving always writes version 2. +# --------------------------------------------------------------------------- +def section_profiles_and_files() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + pm.add_parameter("lo.frequency", initial_value=5e9, unit="Hz") + pm.add_parameter("q01.IF", initial_value=10e6, unit="Hz") + + # a Follower, so the profile file shows a lock entry + pm.lock("q01.IF", "lo.frequency") + pm.toFile() + + profile = Path.cwd() / "parameter_manager-parameter_manager.json" + assert profile.exists() + document = json.loads(profile.read_text()) + assert document["version"] == 2 + assert sorted(document) == ["parameters", "types", "version"] + assert document["parameters"]["parameter_manager.q01.IF"] == { + "unit": "Hz", + "value": 10000000.0, + "lock": {"target": "parameter_manager.lo.frequency", "locked": True}, + } + # the Target stores no lock entry, and its own current value + assert document["parameters"]["parameter_manager.lo.frequency"] == { + "unit": "Hz", + "value": 5000000000.0, + } + assert document["types"] == {} + + # fromFile restores the saved state: the own value of the + # Follower and its Lock, so it reads the Target again + pm.lo.frequency.set(6e9) + pm.fromFile() + assert pm.lo.frequency() == 5000000000.0 + assert pm.q01.IF() == 5000000000.0 + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager.lo.frequency", locked=True + ) + + # deleteMissing removes the parameters the document does not + # list; deleteMissing=False keeps them + pm.add_parameter("temp.extra", initial_value=1) + document = pm.toParamDict() + del document["parameters"]["parameter_manager.temp.extra"] + pm.fromParamDict(document, deleteMissing=False) + assert pm.has_param("temp.extra") + pm.fromParamDict(document) + assert not pm.has_param("temp.extra") + + # a second profile: saving to a profile file also selects it; + # a plain name lands in the working directory as + # parameter_manager-cooldown.json + pm.toFile(name="cooldown") + assert sorted(pm.refresh_profiles()) == [ + "parameter_manager-cooldown.json", + "parameter_manager-parameter_manager.json", + ] + assert sorted(pm.list_profiles()) == [ + "parameter_manager-cooldown.json", + "parameter_manager-parameter_manager.json", + ] + + # the cooldown profile holds a different state: unlocked, and a + # different value + pm.unlock("q01.IF") + pm.lo.frequency.set(8e9) + + # the save selected cooldown, so switch back to the default + # profile first; the state comes back from the file + pm.switch_to_profile("parameter_manager") + assert pm.lo.frequency() == 5000000000.0 + assert pm.get_lock("q01.IF").locked is True + + # change the live state, then switch: the leaving profile is + # saved first, everything is cleared, then cooldown is loaded + pm.lo.frequency.set(9e9) + pm.switch_to_profile("cooldown") + assert pm.lo.frequency() == 8000000000.0 + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager.lo.frequency", locked=False + ) + + # a file without a version key is the legacy flat map: it loads + # as parameters only. Two of its parameters carry an unlocked + # Lock in-session first, to observe what the load does to Locks + # (a locked Follower would refuse the load's set, D6) + pm.add_parameter("old_target", initial_value=5, unit="Hz") + pm.add_parameter("old_param", initial_value=1, unit="M") + pm.lock("old_param", "old_target") + pm.unlock("old_param") + legacy = Path.cwd() / "parameter_manager-legacy.json" + legacy.write_text( + json.dumps( + { + "parameter_manager.old_target": {"unit": "Hz", "value": 5}, + "parameter_manager.old_param": {"unit": "M", "value": 123}, + } + ) + ) + pm.fromFile("parameter_manager-legacy.json") + # the parameters the file does not list are removed, their Locks + # with them; the Lock between the two listed parameters stays, + # untouched by the reader + assert not pm.has_param("lo.frequency") + assert not pm.has_param("q01.IF") + assert pm.list_locks() == { + "old_param": PMLockBluePrint( + target="parameter_manager.old_target", locked=False + ) + } + pm.update() + assert pm.old_param() == 123 + assert pm.old_target() == 5 + + # saving always writes version 2, even over a legacy file; and + # loading a profile file selected it, so the save lands there + pm.toFile() + assert json.loads(legacy.read_text())["version"] == 2 + print("section_profiles_and_files: OK") + + +# --------------------------------------------------------------------------- +# Section: The GUI +# +# The GUI's behaviour (the Parameters and Types tabs, tints and gutter +# bands, the "locked to" column and per-row lock button, the context menu, +# the arm strip, the Locks panel, the Types tab panes, and the delete-Target +# confirmation) cannot be asserted from a script; it is verified manually +# (plan task 5.6 records the end-to-end GUI check) and captured in the +# page's screenshots. The same goes for the Server window showing the +# generic instrument widget unless the station config's gui entry names the +# Parameter Manager widget. The keyboard shortcuts the page lists come from +# the GUI's shortcut registry, and that is asserted here. +# --------------------------------------------------------------------------- +def section_the_gui() -> None: + from instrumentserver.gui.shortcuts import KeyboardShortcutManager + + registry = KeyboardShortcutManager.REGISTRY + expected = { + "delete_item": ("Ctrl+Backspace", "Delete the selected parameter"), + "toggle_locks": ("Ctrl+Shift+L", "Show or hide the Locks panel"), + "lock_to": ("Ctrl+L", "Lock the selected parameter to… (pick a Target)"), + "unlock_item": ("Ctrl+U", "Unlock the selected parameter"), + "show_types": ( + "Ctrl+Shift+Y", + "Switch between the Parameters and Types tabs", + ), + "add_item": ("Ctrl+N", "Jump cursor to the add parameter bar"), + "load_items": ("Ctrl+Shift+O", "Load parameters from JSON file"), + "save_items": ("Ctrl+Shift+S", "Save parameters to JSON file"), + "refresh_all": ("Ctrl+Shift+R", "Refresh all parameters from instrument"), + } + for action_id, entry in expected.items(): + assert registry[action_id] == entry, (action_id, registry[action_id]) + print("section_the_gui: OK") + + +# --------------------------------------------------------------------------- +# Section: Using it from measurement code +# +# Page claims: a measurement script finds (or creates) the Parameter Manager +# through a Client; a Type keeps repeated structures complete and a Type +# Lock shares one value; the sweep sets the Globals Target and reads the +# Followers through the Proxy; setting a locked Follower raises, so a +# deviating parameter is unlocked first and rejoins with relock; the profile +# is saved at the end of the experiment. +# --------------------------------------------------------------------------- +def section_using_it_from_measurement_code() -> None: + with workspace(), server(): + with client() as cli: + pm = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + + # one-time setup: the parameters the experiment needs + pm.add_parameter("power", initial_value=-10, unit="dBm") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=10e6, unit="Hz") + pm.add_instance("qubit", "q0") + pm.add_instance("qubit", "q1") + pm.update() + + # one shared IF for both qubits: the Type Lock's Globals Target + pm.lock_type_parameter("qubit", "IF") + + # the sweep: set the Target, read the Followers (the page shows + # this loop with print, so the script runs the same expressions) + printed = [] + for if_hz in (10e6, 11e6, 12e6): + cli.call("parameter_manager.set", "_globals.qubit.IF", if_hz) + printed.append(f"{pm.q0.IF()} {pm.q1.IF()}") + assert printed == [ + "10000000.0 10000000.0", + "11000000.0 11000000.0", + "12000000.0 12000000.0", + ], printed + + # a locked Follower refuses set: unlock first, then rejoin + try: + pm.q0.IF.set(13e6) + deviate_error = None + except Exception as exc: # noqa: BLE001 + deviate_error = str(exc) + assert deviate_error is not None + assert "is locked to" in deviate_error, deviate_error + pm.unlock("q0.IF") + pm.q0.IF.set(13e6) + assert pm.q0.IF() == 13000000.0 + assert pm.q1.IF() == 12000000.0 + pm.relock("q0.IF") + assert pm.q0.IF() == 12000000.0 + + # end of the experiment: save the profile + pm.toFile() + assert (Path.cwd() / "parameter_manager-parameter_manager.json").exists() + print("section_using_it_from_measurement_code: OK") + + +if __name__ == "__main__": + section_concept() + section_hierarchical_parameters() + section_types() + section_locks() + section_type_locks_and_globals() + section_profiles_and_files() + section_the_gui() + section_using_it_from_measurement_code() + print("verify_parameter_manager: all sections OK") diff --git a/test/notebooks/Prototype the ParamManager.ipynb b/test/notebooks/Prototype the ParamManager.ipynb index ff1b4c3..87a2e21 100644 --- a/test/notebooks/Prototype the ParamManager.ipynb +++ b/test/notebooks/Prototype the ParamManager.ipynb @@ -34,7 +34,7 @@ "from qcodes.utils import validators\n", "\n", "from instrumentserver.gui import widgetDialog\n", - "from instrumentserver.gui.instruments import ParameterManagerGui\n", + "from instrumentserver.gui.parameter_manager import ParameterManagerGui\n", "from instrumentserver.params import ParameterManager\n", "from instrumentserver.serialize import (\n", " toDataFrame,\n", diff --git a/test/prototyping/testing_parameter_manager.py b/test/prototyping/testing_parameter_manager.py index 9161dc9..a3b429f 100755 --- a/test/prototyping/testing_parameter_manager.py +++ b/test/prototyping/testing_parameter_manager.py @@ -6,7 +6,7 @@ from instrumentserver.client import Client, ProxyInstrument from instrumentserver.gui import widgetDialog -from instrumentserver.gui.instruments import ParameterManagerGui +from instrumentserver.gui.parameter_manager import ParameterManagerGui from instrumentserver.params import ParameterManager # %% run the PM locally diff --git a/test/pytest/conftest.py b/test/pytest/conftest.py index 8abfed9..75a2c9f 100644 --- a/test/pytest/conftest.py +++ b/test/pytest/conftest.py @@ -1,11 +1,46 @@ +import random +import socket +import time +from contextlib import contextmanager + import pytest # type: ignore[import-not-found] import qcodes as qc +from instrumentserver import QtCore from instrumentserver.client.core import BaseClient -from instrumentserver.client.proxy import Client +from instrumentserver.client.proxy import Client, SubClient from instrumentserver.server.core import startServer +@pytest.fixture(scope="session") +def server_port(): + """Pick a free pair of consecutive ports, once per pytest session. + + The Server binds ``port`` for requests and uses ``port + 1`` for + Broadcasts, so both must be free. Several agents run the suite in + parallel; fixed ports made those runs collide. The first port is + drawn randomly from a wide range — deliberately outside the OS + ephemeral port range, whose sequential allocation hands concurrently + starting sessions adjacent, overlapping pairs — and both ports are + then verified to be free. + """ + + def _pair_is_free(port): + try: + with socket.socket() as first, socket.socket() as second: + first.bind(("", port)) + second.bind(("", port + 1)) + return True + except OSError: + return False + + for _ in range(100): + port = random.randrange(20_000, 40_000) + if _pair_is_free(port): + return port + raise RuntimeError("Could not find two free consecutive ports.") + + @pytest.fixture(autouse=True, scope="module") def _close_instruments_between_modules(): """Ensure every test module starts with a clean qcodes instrument registry. @@ -37,14 +72,14 @@ def qapp_session(): @pytest.fixture(scope="module") -def start_server(qapp_session): - server, thread = startServer() +def start_server(qapp_session, server_port): + server, thread = startServer(port=server_port) yield server # The zmq loop in StationServer blocks on poll(); thread.quit() on its own # won't interrupt it. Send the SAFEWORD so the server shuts itself down, # then wait for the thread's event loop to exit. try: - with BaseClient() as shutdown_cli: + with BaseClient(port=server_port) as shutdown_cli: shutdown_cli.ask(server.SAFEWORD) except Exception: pass @@ -53,8 +88,8 @@ def start_server(qapp_session): @pytest.fixture() -def cli(start_server): - cli = Client() +def cli(start_server, server_port): + cli = Client(port=server_port) yield cli cli.disconnect() @@ -74,3 +109,60 @@ def param_manager(cli): "parameter_manager", "instrumentserver.params.ParameterManager" ) return cli, params + + +# --------------------------------------------------------------------------- +# Broadcast capture helpers, shared by the proxy tests (test_broadcaster.py, +# test_param_manager.py, test_pm_locks.py). They are exposed as fixtures so +# test modules do not import from conftest directly. +# --------------------------------------------------------------------------- + + +@contextmanager +def _capture_broadcasts(instruments, sub_port): + """Run a SubClient on its own QThread and collect the Broadcasts it receives. + + Mirrors the pattern of ``test/docs_verification/helpers.py``, but takes + the Broadcast port explicitly so tests pass the ``server_port`` fixture's + Broadcast port (``server_port + 1``). + """ + received = [] + sub = SubClient(instruments=instruments, sub_host="localhost", sub_port=sub_port) + sub.update.connect(received.append, QtCore.Qt.DirectConnection) + thread = QtCore.QThread() + sub.moveToThread(thread) + thread.started.connect(sub.connect) + sub.finished.connect(thread.quit) + thread.start() + # PUB/SUB slow joiner: let the SUB socket connect before Broadcasts fire. + time.sleep(0.3) + try: + yield received + finally: + sub.stop() + thread.wait(2000) + thread.deleteLater() + + +def _wait_for_broadcasts(received, n=1, timeout=5.0): + """Block until at least ``n`` Broadcasts arrived, or fail with a report.""" + deadline = time.monotonic() + timeout + while len(received) < n: + if time.monotonic() > deadline: + raise AssertionError( + f"Expected {n} Broadcast(s) within {timeout}s, " + f"got {len(received)}: {received!r}" + ) + time.sleep(0.05) + + +@pytest.fixture(scope="session") +def capture_broadcasts(): + """The :func:`_capture_broadcasts` context manager factory.""" + return _capture_broadcasts + + +@pytest.fixture(scope="session") +def wait_for_broadcasts(): + """The :func:`_wait_for_broadcasts` wait helper.""" + return _wait_for_broadcasts diff --git a/test/pytest/fixtures/parameter_manager-legacy.json b/test/pytest/fixtures/parameter_manager-legacy.json new file mode 100644 index 0000000..e64e00f --- /dev/null +++ b/test/pytest/fixtures/parameter_manager-legacy.json @@ -0,0 +1,10 @@ +{ + "parameter_manager.my_param": { + "value": 123, + "unit": "M" + }, + "parameter_manager.nested_param.child": { + "value": 456, + "unit": "a" + } +} diff --git a/test/pytest/test_apps.py b/test/pytest/test_apps.py index 4469cf9..a22c112 100644 --- a/test/pytest/test_apps.py +++ b/test/pytest/test_apps.py @@ -19,6 +19,8 @@ import pytest +from instrumentserver import DEFAULT_PORT + # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- @@ -113,7 +115,7 @@ def test_server_script_gui_default_no_config(): mock_lc.assert_not_called() kwargs = mock_gui.call_args.kwargs - assert kwargs["port"] == 5555 + assert kwargs["port"] == DEFAULT_PORT assert kwargs["addresses"] is None assert kwargs["serverConfig"] is None assert kwargs["stationConfig"] is None @@ -290,7 +292,9 @@ def test_client_station_script_no_config(): clientStationScript() - mock_cs.assert_called_once_with(host="localhost", port=5555, config_path=None) + mock_cs.assert_called_once_with( + host="localhost", port=DEFAULT_PORT, config_path=None + ) def test_client_station_script_with_config(tmp_path): @@ -307,11 +311,14 @@ def test_client_station_script_with_config(tmp_path): clientStationScript() - mock_cs.assert_called_once_with(host="localhost", port=5555, config_path=cfg) + mock_cs.assert_called_once_with( + host="localhost", port=DEFAULT_PORT, config_path=cfg + ) def test_detached_server_script_defaults(): - """detachedServerScript: defaults → DetachedServerGui called with host=localhost, port=5555.""" + """detachedServerScript: defaults → DetachedServerGui called with the + default host and the package's default port.""" sys.argv = ["instrumentserver-detached"] with ( patch("instrumentserver.apps.QtWidgets.QApplication") as mock_app, @@ -323,7 +330,7 @@ def test_detached_server_script_defaults(): detachedServerScript() - mock_dsg.assert_called_once_with(host="localhost", port=5555) + mock_dsg.assert_called_once_with(host="localhost", port=DEFAULT_PORT) def test_detached_server_script_custom(): @@ -350,8 +357,12 @@ def test_detached_server_script_custom(): def test_param_manager_script_instrument_exists(): - """parameterManagerScript: instrument exists → get_instrument path taken.""" - sys.argv = ["instrumentserver-param-manager", "--port", "5555"] + """parameterManagerScript: instrument exists → get_instrument path taken. + + The GUI must listen for Broadcasts on the server's broadcast port + (request port + 1), not on the default port. + """ + sys.argv = ["instrumentserver-param-manager", "--port", "4567"] mock_pm = MagicMock() mock_cli = MagicMock() mock_cli.list_instruments.return_value = ["parameter_manager"] @@ -370,13 +381,17 @@ def test_param_manager_script_instrument_exists(): mock_cli.get_instrument.assert_called_once_with("parameter_manager") mock_cli.find_or_create_instrument.assert_not_called() - mock_pmg.assert_called_once_with(mock_pm) + mock_pmg.assert_called_once_with(mock_pm, sub_port=4568, sub_host="localhost") mock_wmw.assert_called_once() def test_param_manager_script_instrument_missing(): - """parameterManagerScript: instrument not found → find_or_create path taken.""" - sys.argv = ["instrumentserver-param-manager", "--port", "5555"] + """parameterManagerScript: instrument not found → find_or_create path taken. + + The GUI must listen for Broadcasts on the server's broadcast port + (request port + 1), not on the default port. + """ + sys.argv = ["instrumentserver-param-manager", "--port", "4567"] mock_pm = MagicMock() mock_cli = MagicMock() mock_cli.list_instruments.return_value = [] @@ -399,7 +414,7 @@ def test_param_manager_script_instrument_missing(): mock_cli.get_instrument.assert_not_called() mock_pm.fromFile.assert_called_once() mock_pm.update.assert_called_once() - mock_pmg.assert_called_once_with(mock_pm) + mock_pmg.assert_called_once_with(mock_pm, sub_port=4568, sub_host="localhost") mock_wmw.assert_called_once() diff --git a/test/pytest/test_broadcaster.py b/test/pytest/test_broadcaster.py new file mode 100644 index 0000000..53b2e91 --- /dev/null +++ b/test/pytest/test_broadcaster.py @@ -0,0 +1,268 @@ +"""Tests for the Broadcaster contract (``instrumentserver.base``) and its +Server-side registration (``instrumentserver.server.core``). + +The unit part needs no Server: it exercises the mixin's own behaviour — +registering and removing sinks, fanning a Broadcast out to the sinks, +tolerating an exception in one sink, and doing nothing without sinks. +The Server part creates a Broadcaster instrument through a client, makes it +emit a Broadcast, and checks that a SubClient receives it; a plain dummy +instrument is checked to still work and to stay sink-free. +""" + +import logging + +import qcodes as qc + +from instrumentserver.base import Broadcaster +from instrumentserver.blueprints import ( + PARAMETER_CALL, + PARAMETER_CREATION, + PARAMETER_DELETION, + PARAMETER_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ParameterBroadcastBluePrint, +) +from instrumentserver.config import loadConfig +from instrumentserver.params import ParameterManager +from instrumentserver.server.core import StationServer + + +def make_bp( + name: str = "pm.q01.IF", + action: str = "parameter-update", + value: float = 1.0, + unit: str = "Hz", +) -> ParameterBroadcastBluePrint: + return ParameterBroadcastBluePrint(name=name, action=action, value=value, unit=unit) + + +# --------------------------------------------------------------------------- +# broadcasting behaviour of the mixin +# --------------------------------------------------------------------------- + + +def test_broadcast_without_sinks_is_a_noop(): + bc = Broadcaster() + bc.broadcast(make_bp()) # must not raise + + +def test_broadcast_reaches_the_added_sink(): + bc = Broadcaster() + bp = make_bp() + received = [] + bc.add_broadcast_sink(received.append) + bc.broadcast(bp) + assert received == [bp] + + +def test_broadcast_reaches_all_sinks_in_registration_order(): + bc = Broadcaster() + order = [] + bc.add_broadcast_sink(lambda bp: order.append("first")) + bc.add_broadcast_sink(lambda bp: order.append("second")) + bc.broadcast(make_bp()) + assert order == ["first", "second"] + + +def test_adding_the_same_sink_twice_delivers_twice(): + """Pins the documented semantics: sinks are stored in a plain list, so a + sink registered twice receives every Broadcast twice, and a single + remove leaves it registered once.""" + bc = Broadcaster() + bp = make_bp() + received = [] + bc.add_broadcast_sink(received.append) + bc.add_broadcast_sink(received.append) + + bc.broadcast(bp) + assert len(received) == 2 + assert received == [bp, bp] + + bc.remove_broadcast_sink(received.append) + bc.broadcast(bp) + assert len(received) == 3 + assert received == [bp, bp, bp] + + +def test_exception_in_one_sink_is_logged_and_others_still_run(caplog): + bc = Broadcaster() + bp = make_bp() + received = [] + + def failing_sink(bp): + raise RuntimeError("sink is broken") + + bc.add_broadcast_sink(failing_sink) + bc.add_broadcast_sink(received.append) + + with caplog.at_level(logging.ERROR, logger="instrumentserver.base"): + bc.broadcast(bp) + + assert received == [bp] + error_records = [r for r in caplog.records if r.levelno == logging.ERROR] + assert len(error_records) == 1 + assert "failing_sink" in error_records[0].getMessage() + assert error_records[0].exc_info is not None + + +def test_removed_sink_no_longer_receives_broadcasts(): + bc = Broadcaster() + received = [] + bc.add_broadcast_sink(received.append) + bc.remove_broadcast_sink(received.append) + bc.broadcast(make_bp()) + assert received == [] + + +def test_removing_a_sink_that_was_never_added_is_a_noop(): + bc = Broadcaster() + + def unknown_sink(bp): + pass + + bc.remove_broadcast_sink(unknown_sink) # must not raise + + +# --------------------------------------------------------------------------- +# Broadcast action strings +# (the constants every emitter and consumer shares; their values are the +# wire contract, so they are pinned here) +# --------------------------------------------------------------------------- + + +def test_broadcast_action_constants_pin_the_wire_strings(): + """The action of a Broadcast is a plain string on the wire, and external + subscribers parse these exact strings unchanged (ADR-0003). Every + in-repo emitter and consumer now shares the constants, so the whole + suite would pass even if a constant's value drifted; pinning the values + here keeps that drift from silently breaking external subscribers. + """ + assert PARAMETER_UPDATE == "parameter-update" + assert PARAMETER_CALL == "parameter-call" + assert PARAMETER_CREATION == "parameter-creation" + assert PARAMETER_DELETION == "parameter-deletion" + assert PM_LOCK_UPDATE == "pm-lock-update" + assert PM_TYPE_UPDATE == "pm-type-update" + + +# --------------------------------------------------------------------------- +# Parameter Manager implements the Broadcaster contract +# (it emits nothing on its own yet; here we only prove the mixin machinery +# works on a real Parameter Manager) +# --------------------------------------------------------------------------- + + +def test_parameter_manager_is_a_broadcaster(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + pm = ParameterManager(name="params") + assert isinstance(pm, Broadcaster) + assert callable(pm.add_broadcast_sink) + assert callable(pm.remove_broadcast_sink) + assert callable(pm.broadcast) + + +def test_parameter_manager_broadcast_reaches_sink(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + pm = ParameterManager(name="params") + received = [] + pm.add_broadcast_sink(received.append) + bp = make_bp() + pm.broadcast(bp) + assert received == [bp] + + pm.remove_broadcast_sink(received.append) + pm.broadcast(make_bp()) + assert received == [bp] + + +# --------------------------------------------------------------------------- +# Server part: the Server registers itself as a sink on Broadcaster +# instruments that join the Station, and a Broadcast emitted by such an +# instrument reaches a SubClient through the Server's PUB socket. +# --------------------------------------------------------------------------- + +BROADCASTER_INSTRUMENT_CLASS = ( + "instrumentserver.testing.dummy_instruments.generic.DummyBroadcasterInstrument" +) + + +def test_created_broadcaster_instrument_reaches_subclient( + cli, start_server, server_port, capture_broadcasts, wait_for_broadcasts +): + """A Broadcaster instrument created through a client has the Server as a + sink, and a method call that emits a blueprint arrives at a SubClient.""" + inst = cli.find_or_create_instrument("bcaster", BROADCASTER_INSTRUMENT_CLASS) + + # The Server registered itself as a sink on the instrument in the Station. + server_instrument = start_server.station.components["bcaster"] + assert start_server._broadcastParameterChange in server_instrument._broadcast_sinks + + with capture_broadcasts(["bcaster"], server_port + 1) as received: + inst.emit_broadcast(value=2.5, unit="V") + + wait_for_broadcasts(received) + # exactly one message: the Server registered itself once + assert len(received) == 1 + bp = received[0] + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "bcaster.param0" + assert bp.action == "parameter-update" + assert float(bp.value) == 2.5 + assert bp.unit == "V" + + +def test_plain_dummy_instrument_still_works_and_gets_no_sink( + dummy_instrument, start_server +): + """A plain dummy instrument keeps working over the wire, and since it does + not implement the Broadcaster contract the Server registers no sink.""" + cli, dummy = dummy_instrument + + dummy.param0(0.5) + assert dummy.param0() == 0.5 + + server_dummy = start_server.station.components["dummy"] + assert not hasattr(server_dummy, "add_broadcast_sink") + + +def test_config_loaded_broadcaster_instrument_gets_sink( + tmp_path, server_port, qapp_session +): + """Instruments that reach the Station from a config file get the Server + registered as a Broadcast sink in ``StationServer.__init__`` (ADR-0003). + + Mirrors the production config path: an instrumentserver YAML like + ``test/docs_verification/getting_started/quickstartConfig.yml`` is split + by ``loadConfig`` into a station config and a serverConfig, and the + Server registers itself on every component the Station was loaded with. + The StationServer is constructed directly — registration happens in + ``__init__``, so no thread or socket bind is needed. + """ + config = tmp_path / "serverConfig.yml" + config.write_text( + "instruments:\n" + " cfg_bcaster:\n" + " type: instrumentserver.testing.dummy_instruments.generic." + "DummyBroadcasterInstrument\n" + " initialize: True\n" + ) + stationConfigPath, serverConfig, _, _, tempFile, _, _ = loadConfig(config) + + server = StationServer( + port=server_port, serverConfig=serverConfig, stationConfig=stationConfigPath + ) + try: + assert "cfg_bcaster" in server.station.components + component = server.station.components["cfg_bcaster"] + assert isinstance(component, Broadcaster) + assert server._broadcastParameterChange in component._broadcast_sinks + assert len(component._broadcast_sinks) == 1 + finally: + # The StationServer was never started (no thread, no bound sockets); + # close what it opened so the other tests keep a clean qcodes state. + tempFile.close() + server._wakeup_r.close() + server._wakeup_w.close() + if qc.Instrument.exist("cfg_bcaster"): + qc.Instrument.find_instrument("cfg_bcaster").close() diff --git a/test/pytest/test_client_station.py b/test/pytest/test_client_station.py index 495ceee..ffaf24e 100644 --- a/test/pytest/test_client_station.py +++ b/test/pytest/test_client_station.py @@ -15,8 +15,8 @@ @pytest.fixture(scope="module") -def client_station(start_server): - station = ClientStation(host="localhost", port=5555) +def client_station(start_server, server_port): + station = ClientStation(host="localhost", port=server_port) yield station station.disconnect() @@ -73,10 +73,10 @@ def test_client_station_subscript_access(client_station): # --------------------------------------------------------------------------- -def test_client_station_gui_opens(qtbot, start_server): +def test_client_station_gui_opens(qtbot, start_server, server_port): from instrumentserver.client.application import ClientStationGui - station = ClientStation(host="localhost", port=5555) + station = ClientStation(host="localhost", port=server_port) window = ClientStationGui(station) qtbot.addWidget(window) try: @@ -86,10 +86,10 @@ def test_client_station_gui_opens(qtbot, start_server): station.disconnect() -def test_client_station_gui_has_three_tabs(qtbot, start_server): +def test_client_station_gui_has_three_tabs(qtbot, start_server, server_port): from instrumentserver.client.application import ClientStationGui - station = ClientStation(host="localhost", port=5555) + station = ClientStation(host="localhost", port=server_port) window = ClientStationGui(station) qtbot.addWidget(window) try: @@ -102,24 +102,26 @@ def test_client_station_gui_has_three_tabs(qtbot, start_server): station.disconnect() -def test_client_station_gui_server_widget_shows_host_port(qtbot, start_server): +def test_client_station_gui_server_widget_shows_host_port( + qtbot, start_server, server_port +): from instrumentserver.client.application import ClientStationGui - station = ClientStation(host="localhost", port=5555) + station = ClientStation(host="localhost", port=server_port) window = ClientStationGui(station) qtbot.addWidget(window) try: assert window.server_widget.host.text() == "localhost" - assert window.server_widget.port.text() == "5555" + assert window.server_widget.port.text() == str(server_port) finally: window.close() station.disconnect() -def test_client_station_gui_station_list_populated(qtbot, start_server): +def test_client_station_gui_station_list_populated(qtbot, start_server, server_port): from instrumentserver.client.application import ClientStationGui - station = ClientStation(host="localhost", port=5555) + station = ClientStation(host="localhost", port=server_port) station.find_or_create_instrument("gui_cs_dummy", DUMMY_CLASS) window = ClientStationGui(station) qtbot.addWidget(window) @@ -130,12 +132,12 @@ def test_client_station_gui_station_list_populated(qtbot, start_server): station.disconnect() -def test_client_station_gui_open_instrument_tab(qtbot, start_server): +def test_client_station_gui_open_instrument_tab(qtbot, start_server, server_port): from instrumentserver import QtCore from instrumentserver.client.application import ClientStationGui from instrumentserver.gui.instruments import GenericInstrument - station = ClientStation(host="localhost", port=5555) + station = ClientStation(host="localhost", port=server_port) station.find_or_create_instrument("gui_cs_dummy2", DUMMY_CLASS) window = ClientStationGui(station) qtbot.addWidget(window) diff --git a/test/pytest/test_gui_navigation.py b/test/pytest/test_gui_navigation.py index e620882..de36867 100644 --- a/test/pytest/test_gui_navigation.py +++ b/test/pytest/test_gui_navigation.py @@ -21,15 +21,15 @@ def _shutdown_server_window(qtbot, window): qtbot.waitUntil(lambda: not thread.isRunning(), timeout=10000) -# Use a spare port so these tests never talk to a developer's live server on 5555. -TEST_PORT = 5599 - +# Each pytest session gets its own free port pair from the server_port +# fixture, so these tests never collide with a developer's live server or +# another concurrently running test suite. TIMEOUT_INS = ( "instrumentserver.testing.dummy_instruments.generic.DummyInstrumentTimeout" ) -def _start_window(qtbot): +def _start_window(qtbot, port): """Create the server window and wait until its embedded client points at the test server. @@ -37,9 +37,9 @@ def _start_window(qtbot): only re-targets the real server port once the event loop delivers the server-started signal, so the first request must not be sent before then. """ - window = startServerGuiApplication(port=TEST_PORT) + window = startServerGuiApplication(port=port) qtbot.addWidget(window) - qtbot.waitUntil(lambda: window.client.addr.endswith(f":{TEST_PORT}"), timeout=10000) + qtbot.waitUntil(lambda: window.client.addr.endswith(f":{port}"), timeout=10000) return window @@ -62,8 +62,8 @@ def _find_row(view, text): return matches[0] -def test_backspace_does_not_blank_read_only_parameter(qtbot): - window = _start_window(qtbot) +def test_backspace_does_not_blank_read_only_parameter(qtbot, server_port): + window = _start_window(qtbot, server_port) try: tab = _open_instrument_tab(window, "timeout", TIMEOUT_INS) params = tab.parametersList @@ -84,8 +84,8 @@ def test_backspace_does_not_blank_read_only_parameter(qtbot): _shutdown_server_window(qtbot, window) -def test_backspace_clears_editable_parameter(qtbot): - window = _start_window(qtbot) +def test_backspace_clears_editable_parameter(qtbot, server_port): + window = _start_window(qtbot, server_port) try: tab = _open_instrument_tab(window, "timeout", TIMEOUT_INS) params = tab.parametersList @@ -109,8 +109,8 @@ def test_backspace_clears_editable_parameter(qtbot): ) -def test_enter_toggles_node_with_children(qtbot): - window = _start_window(qtbot) +def test_enter_toggles_node_with_children(qtbot, server_port): + window = _start_window(qtbot, server_port) try: tab = _open_instrument_tab(window, "dummy", SUBMODULE_INS) view = tab.parametersList.view @@ -132,8 +132,8 @@ def test_enter_toggles_node_with_children(qtbot): _shutdown_server_window(qtbot, window) -def test_right_expands_node_then_moves_to_first_child(qtbot): - window = _start_window(qtbot) +def test_right_expands_node_then_moves_to_first_child(qtbot, server_port): + window = _start_window(qtbot, server_port) try: tab = _open_instrument_tab(window, "dummy", SUBMODULE_INS) view = tab.parametersList.view @@ -152,8 +152,8 @@ def test_right_expands_node_then_moves_to_first_child(qtbot): _shutdown_server_window(qtbot, window) -def test_enter_on_parameter_requests_edit(qtbot): - window = _start_window(qtbot) +def test_enter_on_parameter_requests_edit(qtbot, server_port): + window = _start_window(qtbot, server_port) try: tab = _open_instrument_tab(window, "dummy", SUBMODULE_INS) view = tab.parametersList.view diff --git a/test/pytest/test_log_widget.py b/test/pytest/test_log_widget.py new file mode 100644 index 0000000..c59fa97 --- /dev/null +++ b/test/pytest/test_log_widget.py @@ -0,0 +1,104 @@ +"""The GUI log handler: records reach the log widget from any thread, and +nothing it leaves behind raises once Qt has deleted the widget — neither a +later record nor ``logging.shutdown`` at interpreter exit.""" + +import logging +import os +import subprocess +import sys +import textwrap +import threading + +from qtpy import sip + +from instrumentserver.log import LogWidget, QLogHandler + +LOGGER = "instrumentserver" + + +def test_a_record_reaches_the_widget(qtbot): + widget = LogWidget() + qtbot.addWidget(widget) + try: + logging.getLogger(LOGGER).warning("hello from the test") + qtbot.waitUntil( + lambda: "hello from the test" in widget.handler.widget.toPlainText() + ) + finally: + logging.getLogger(LOGGER).removeHandler(widget.handler) + + +def test_a_record_from_another_thread_reaches_the_widget(qtbot): + """The server logs from its own thread; the line must still be + appended (in the GUI thread, through the bridge's signal).""" + widget = LogWidget() + qtbot.addWidget(widget) + try: + thread = threading.Thread( + target=lambda: logging.getLogger(LOGGER).warning("from a thread") + ) + thread.start() + thread.join() + qtbot.waitUntil(lambda: "from a thread" in widget.handler.widget.toPlainText()) + finally: + logging.getLogger(LOGGER).removeHandler(widget.handler) + + +def test_a_record_after_the_widget_is_deleted_detaches_the_handler(qtbot): + widget = LogWidget() + handler = widget.handler + assert isinstance(handler, QLogHandler) + sip.delete(widget) + logger = logging.getLogger(LOGGER) + with qtbot.captureExceptions() as exceptions: + logger.warning("nobody is listening") + assert exceptions == [] + assert handler not in logger.handlers + + +def test_detaching_a_dead_handler_does_not_skip_the_next_handler(qtbot): + """A dead handler detaches itself while the logger is looping over its + handlers; the handler after it must still get the record.""" + logger = logging.getLogger(LOGGER) + first = LogWidget() + first_handler = first.handler + sip.delete(first) + second = LogWidget() + second_handler = second.handler + sip.delete(second) + with qtbot.captureExceptions() as exceptions: + logger.warning("both dead handlers should detach") + assert exceptions == [] + assert first_handler not in logger.handlers + assert second_handler not in logger.handlers + + +def test_closing_the_app_leaves_no_traceback_from_the_log_handler(): + """Qt deletes the log widget before the interpreter exits, and + ``logging.shutdown`` then visits every handler still registered. The + handler used to be a QObject itself, so the exit printed "wrapped + C/C++ object of type QLogHandler has been deleted".""" + script = textwrap.dedent( + """ + import logging + from qtpy import sip + from instrumentserver import QtWidgets + from instrumentserver.log import LogWidget + + app = QtWidgets.QApplication([]) + widget = LogWidget() + logging.getLogger("instrumentserver").warning("hello") + sip.delete(widget) + """ + ) + env = dict(os.environ, QT_QPA_PLATFORM="offscreen") + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + env=env, + timeout=60, + ) + assert result.returncode == 0, result.stderr + assert "Traceback" not in result.stderr, result.stderr + assert "has been deleted" not in result.stderr, result.stderr diff --git a/test/pytest/test_param_manager.py b/test/pytest/test_param_manager.py index 67b168b..e455f69 100644 --- a/test/pytest/test_param_manager.py +++ b/test/pytest/test_param_manager.py @@ -1,6 +1,7 @@ import json -from instrumentserver.params import ParameterManager +from instrumentserver.blueprints import ParameterBroadcastBluePrint +from instrumentserver.params import ParameterGroup, ParameterManager def prep_param_manager(params, template=1): @@ -55,6 +56,28 @@ def test_proxy_add_remove_parameter(param_manager): assert "probe_param" not in params.parameters +def test_add_parameter_without_initial_value_succeeds_and_broadcasts( + param_manager, server_port, capture_broadcasts, wait_for_broadcasts +): + """Calling ``add_parameter("x")`` with no initial_value and no unit over + the wire succeeds and Broadcasts the creation with an empty payload + (the Server's detection must not raise a latent KeyError).""" + cli, params = param_manager + + with capture_broadcasts(["parameter_manager"], server_port + 1) as received: + params.add_parameter("x") + wait_for_broadcasts(received) + + assert "x" in params.parameters + assert len(received) == 1 + bp = received[0] + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "parameter_manager.x" + assert bp.action == "parameter-creation" + assert bp.value is None + assert bp.unit == "" + + def test_removing_all_params(): params = ParameterManager(name="params") @@ -112,7 +135,7 @@ def test_saving_correct_profile(tmp_path): with open(file_path) as file: data = json.load(file) - assert data["params.my_param"]["value"] == 8888 + assert data["parameters"]["params.my_param"]["value"] == 8888 def test_loading_correct_profile(tmp_path): @@ -127,7 +150,7 @@ def test_loading_correct_profile(tmp_path): with open(file_path) as file: data = json.load(file) - data["params.my_param"]["value"] = 9999 + data["parameters"]["params.my_param"]["value"] = 9999 with open(file_path, "w") as file: json.dump(data, file) @@ -192,9 +215,9 @@ def test_switching_profiles_automatic_save(tmp_path): with open(tmp_path.joinpath("parameter_manager-second.json")) as file: second = json.load(file) - assert second["params.his_param"]["value"] == 111 - assert second["params.nested_param.son1"]["value"] == 222 - assert second["params.nested_param.son2"]["value"] == 333 + assert second["parameters"]["params.his_param"]["value"] == 111 + assert second["parameters"]["params.nested_param.son1"]["value"] == 222 + assert second["parameters"]["params.nested_param.son2"]["value"] == 333 def test_selectedProfile_only_changing_when_correct_name(tmp_path): @@ -215,3 +238,40 @@ def test_selectedProfile_only_changing_when_correct_name(tmp_path): new_path = names_path.replace(tmp_path.joinpath("parameter_manager-names.json")) params.fromFile(new_path) assert params.selectedProfile == "parameter_manager-names.json" + + +def test_submodules_are_groups(caplog): + params = ParameterManager(name="params") + # the root itself may warn about its own missing profile file; + # only warnings from creating the submodule are of interest here + caplog.clear() + + params.add_parameter(name="q01.IF", initial_value=1e9, unit="Hz") + params.add_parameter(name="q01.readout.power", initial_value=-10, unit="dBm") + + assert isinstance(params.q01, ParameterGroup) + assert not isinstance(params.q01, ParameterManager) + assert isinstance(params.q01.readout, ParameterGroup) + assert not isinstance(params.q01.readout, ParameterManager) + + # creating a submodule no longer lists the working directory or + # tries to load a parameter file for it + assert "parameter file not found" not in caplog.text + + +def test_submodule_does_not_load_parameter_file(tmp_path, monkeypatch): + """Submodules are Parameter Groups with no file or profile logic of + their own: a parameter_manager-q01.json file in the working directory + is not loaded into the q01 submodule.""" + monkeypatch.chdir(tmp_path) + profile = tmp_path / "parameter_manager-q01.json" + profile.write_text(json.dumps({"q01.file_param": {"value": 999, "unit": "V"}})) + + params = ParameterManager(name="params") + params.add_parameter(name="q01.my_param", initial_value=1, unit="M") + + assert isinstance(params.q01, ParameterGroup) + assert not isinstance(params.q01, ParameterManager) + assert not params.q01.has_param("file_param") + assert "q01.file_param" not in params.list() + assert params.q01.my_param() == 1 diff --git a/test/pytest/test_pm_gui.py b/test/pytest/test_pm_gui.py new file mode 100644 index 0000000..8d67fd7 --- /dev/null +++ b/test/pytest/test_pm_gui.py @@ -0,0 +1,3643 @@ +"""Client-side state and Broadcast handling of the Parameter Manager GUI +(plan task 5.1), its tabs, tints and gutter bands (plan task 5.2), its +Lock column, lock toggle, context menu and arm strip (plan task 5.3), its +Locks panel (plan task 5.4), and its Types tab (plan task 5.5). + +The GUI keeps the Parameter Manager's Types and Locks in a ``PMState`` +(``ParameterManagerGui.state``), filled from the Parameter Manager on +construction and on every refresh, and kept current by the +``pm-lock-update`` and ``pm-type-update`` Broadcasts the model routes to it. +A second Client's changes must reach that state and the tree's value +widgets without any polling, so every cross-client assertion waits with +``qtbot.waitUntil``. The 5.2 tests cover the tab widget around the +existing view, the pure ``compute_claims`` function and the tint palette +without a Server, and the tints and gutter bands a second Client's Type +edits produce live. The 5.3 tests cover the pure Lock helpers, the arm +strip and the read-only rendering without a Server, and the arm-via-context-menu +flow, the toggle, the refused cycle, the Follower repaint on a second +Client's Target update, and the model reload path live. The 5.4 tests cover +the pure Locks-panel row model without a Server, the toolbar action and +splitter, and the panel's rows, remove and lock-all actions, value editor +and "Lock selection to…" flow live. The 5.5 tests cover the pure Types-pane +helpers (the entries rows, the client-side Instance matching, the "also" +Types and the Type Lock arm ranking) without a Server, the model's +parameter-creation branch (the 5.1 TEST_AUDIT trap, fixed in plan task 5.5 +by Marcos's decision as an exception to plan rule 6), and the Types tab's +three panes live: a Type and an Instance created through the widgets with +the Server state checked, entry and Nested Type edits, the Type Lock +toggle and its re-target through the arm strip, Show, "also" and the +error notes. + +With the creation branch fixed, parameters may be created while the GUI +is open, so the 5.5 tests drive everything through the widgets — and the +Globals-default Type Lock, whose declaration creates the +``_globals..`` parameter, is testable live too. The 5.6 tests +cover the delete-Target confirmation (Cancel leaves the Server untouched, +Ok removes the Target and drops the Locks, a parameter without Followers +goes without a dialog), the three Lock shortcut REGISTRY entries and +their keys (Ctrl+L arm, Ctrl+U unlock, Ctrl+Shift+Y tab switch), the +lock/unlock icons in the compiled resources, and the 5.6 polish: a +parameter-update for an unknown row recomputes the tints, stale notes are +reset on success, and with no Type selected the Types tab strips are +disabled and the pane labels carry no trailing space. +""" + +import importlib +import os + +import pytest +from qcodes.instrument import InstrumentBase + +from instrumentserver import QtCore, QtGui, QtWidgets +from instrumentserver.blueprints import ( + PARAMETER_CALL, + PARAMETER_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ParameterBroadcastBluePrint, + PMLockBluePrint, + PMTypeBluePrint, +) +from instrumentserver.client.proxy import Client +from instrumentserver.gui.base_instrument import InstrumentSortFilterProxyModel +from instrumentserver.gui.instruments import ItemParameters, ModelParameters +from instrumentserver.gui.parameter_manager.logic import ( + GUTTER_COLUMN, + GUTTER_ROLE, + GUTTER_WIDTH, + LOCK_COLUMN, + LOCK_COLUMN_WIDTH, + TINT_COLOURS, + TINT_COLOURS_DARK, + Claim, + PMState, + TypePalette, + also_types, + build_lock_rows, + compute_claims, + followers_reaching, + instances_of_type, + is_dark_theme, + lock_column_text, + lock_root, + parse_default_text, + rank_lock_targets, + relative_path, + tint_colours, + type_entry_rows, +) +from instrumentserver.gui.parameter_manager.panels import ( + LOCK_PANEL_NOTE, + LOCK_ROW_ROLE, + GutterDelegate, + LockArmStrip, +) +from instrumentserver.gui.parameter_manager.widget import ( + ModelParameterManager, + ParameterManagerGui, + ParameterManagerTreeView, +) +from instrumentserver.gui.parameters import ParameterWidget +from instrumentserver.gui.shortcuts import KeyboardShortcutManager + +PM_NAME = "parameter_manager" +PM_CLASS = "instrumentserver.params.ParameterManager" +BROADCAST_TIMEOUT = 5000 + + +@pytest.fixture(scope="module", autouse=True) +def pm_working_directory(tmp_path_factory): + """Run the module in its own working directory. + + The Parameter Manager keeps its profile files in the working directory + of the process it lives in, and the GUI's profile selection needs at + least one profile to exist (``switch_to_profile`` raises otherwise). + The module's Server and its Parameter Manager live in the pytest + process, so switching the directory for the module gives them a + private, throwaway profile directory instead of the repository. + """ + workdir = tmp_path_factory.mktemp("pm_gui_profiles") + previous = os.getcwd() + os.chdir(workdir) + yield workdir + os.chdir(previous) + + +@pytest.fixture(scope="module") +def pm(start_server, server_port): + """The module's Parameter Manager Proxy Instrument, with one profile on + disk so the GUI's profile combo has an entry to switch to.""" + cli = Client(port=server_port) + manager = cli.find_or_create_instrument(PM_NAME, PM_CLASS) + manager.toFile() + manager.refresh_profiles() + yield manager + cli.disconnect() + + +@pytest.fixture(autouse=True) +def clean_parameter_manager(pm): + """Leave the shared Parameter Manager without Locks, parameters or + Types before and after every test.""" + + def clean(): + for follower in list(pm.list_locks()): + pm.remove_lock(follower) + for path in list(pm.list()): + pm.remove_parameter(path) + remaining = list(pm.list_types()) + while remaining: + for name in remaining: + try: + pm.remove_type(name) + except ValueError: + pass # still nested in another Type; goes on a later pass + remaining = list(pm.list_types()) + + clean() + yield + clean() + + +@pytest.fixture() +def second_client(start_server, server_port): + """A second Client on the same Server, acting on the GUI's Parameter + Manager from the outside.""" + cli = Client(port=server_port) + yield cli + cli.disconnect() + + +def _second_parameter_manager(second_client): + """The second Client's Proxy Instrument of the same Parameter Manager.""" + return second_client.find_or_create_instrument(PM_NAME, PM_CLASS) + + +def _make_gui(qtbot, pm, server_port): + """Build the Parameter Manager GUI on the module's Parameter Manager, + listening for Broadcasts on the Server's broadcast port.""" + gui = ParameterManagerGui(pm, sub_host="localhost", sub_port=server_port + 1) + qtbot.addWidget(gui) + return gui + + +def _wait_until_broadcasts_arrive(qtbot, gui, second_pm): + """Make sure the GUI's listener receives Broadcasts before the real + assertions run. + + A zmq SUB socket drops everything published before its subscription + has reached the Server's PUB socket (the slow joiner), so the first + Broadcast after the GUI is built can be lost. Probe with throwaway + Types until one is observed; every later Broadcast then arrives. + """ + qtbot.waitUntil(lambda: gui.model.subClient.connected, timeout=BROADCAST_TIMEOUT) + for attempt in range(3): + name = f"gui_probe_type_{attempt}" + second_pm.add_type(name) + try: + qtbot.waitUntil(lambda: name in gui.state.types, timeout=BROADCAST_TIMEOUT) + except Exception: + continue # the probe Broadcast was lost to the slow joiner + second_pm.remove_type(name) + qtbot.waitUntil(lambda: name not in gui.state.types, timeout=BROADCAST_TIMEOUT) + return + raise AssertionError( + "the GUI's listener received no Broadcast; cannot test live updates" + ) + + +def test_pm_state_helpers_work_without_a_server(): + """PMState fills from a local Parameter Manager and applies the + Broadcast payloads, with no Server involved.""" + from instrumentserver.params import ParameterManager + + manager = ParameterManager("pm_state_local") + manager.add_parameter("q01.x", initial_value=1.0, unit="Hz") + manager.add_parameter("q02.x", initial_value=2.0, unit="Hz") + manager.lock("q02.x", "q01.x") + manager.add_type("qubit") + manager.add_type_parameter("qubit", "IF", default=1.0, unit="Hz") + + state = PMState() + assert state.types == {} + assert state.locks == {} + + state.refresh(manager) + assert isinstance(state.types["qubit"], PMTypeBluePrint) + assert state.locks["q02.x"] == PMLockBluePrint( + target="pm_state_local.q01.x", locked=True + ) + + # a pm-lock-update payload replaces the entry; None (the Lock was + # removed) drops it, and dropping an absent entry is not an error + state.apply_lock( + "q02.x", PMLockBluePrint(target="pm_state_local.q01.x", locked=False) + ) + assert state.locks["q02.x"].locked is False + state.apply_lock("q02.x", None) + assert "q02.x" not in state.locks + state.apply_lock("gone.x", None) + + # same for a Type + state.apply_type("qubit", None) + assert "qubit" not in state.types + state.apply_type("gone", None) + + +class _StubParamWidget: + """Stands in for a ParameterWidget's inner widget of a kind that has no + ``setValue`` (e.g. the QLineEdit of a string parameter or a read-only + QLabel): only the ParameterWidget's ``_setMethod`` reaches it.""" + + def __init__(self): + self.set_via_set_method = [] + + def _setMethod(self, value): + self.set_via_set_method.append(value) + + +class _StubDelegateWidget: + """Stands in for the delegate's ParameterWidget.""" + + def __init__(self): + self.paramWidget = _StubParamWidget() + self.set_via_set_method = [] + + def _setMethod(self, value): + self.set_via_set_method.append(value) + + +def test_on_item_new_value_uses_the_parameter_widget_set_method(qtbot): + """D24 item three: ``ParameterManagerTreeView.onItemNewValue`` delivers + the value through the widget's ``_setMethod``, which every + ParameterWidget kind has — not through ``paramWidget.setValue``, which + only the input widgets have. The stub's paramWidget deliberately has no + ``setValue``, so the old code would raise AttributeError here.""" + stub_instrument = InstrumentBase("pm_tree_stub") + model = ModelParameterManager(stub_instrument, "parameters", ItemParameters) + view = ParameterManagerTreeView(InstrumentSortFilterProxyModel(model)) + qtbot.addWidget(view) + + widget = _StubDelegateWidget() + assert not hasattr(widget.paramWidget, "setValue") + view.delegate.parameters["stub.x"] = widget + try: + view.onItemNewValue("stub.x", 42) + finally: + model.stopListener() + + assert widget.set_via_set_method == [42] + + +def _pm_broadcasts(instrument_name): + """A ``pm-lock-update`` and a ``pm-type-update`` Broadcast about + ``instrument_name``, both reporting a removal.""" + return [ + ParameterBroadcastBluePrint( + name=f"{instrument_name}.q01.IF", action=PM_LOCK_UPDATE, value=None + ), + ParameterBroadcastBluePrint( + name=f"{instrument_name}.qubit", action=PM_TYPE_UPDATE, value=None + ), + ] + + +def test_generic_model_ignores_the_parameter_manager_broadcasts(qtbot): + """The generic ``ModelParameters`` has no Lock or Type signals: an + instrument window opened on a Parameter Manager without the + Parameter Manager GUI receives ``pm-lock-update`` and + ``pm-type-update`` Broadcasts and leaves its rows alone.""" + stub_instrument = InstrumentBase("pm_generic_stub") + model = ModelParameters(stub_instrument, "parameters", ItemParameters) + try: + assert not hasattr(model, "lockChanged") + assert not hasattr(model, "typeChanged") + rows_before = model.rowCount() + for bp in _pm_broadcasts(stub_instrument.name): + model.updateParameter(bp) + assert model.rowCount() == rows_before + finally: + model.stopListener() + + +def test_parameter_manager_model_routes_the_parameter_manager_broadcasts(qtbot): + """``ModelParameterManager`` emits ``lockChanged`` with the Follower's + path and ``typeChanged`` with the Type's name, both relative to the + instrument, and adds no row for either.""" + stub_instrument = InstrumentBase("pm_model_stub") + model = ModelParameterManager(stub_instrument, "parameters", ItemParameters) + locks, types = [], [] + model.lockChanged.connect(lambda path, lock: locks.append((path, lock))) + model.typeChanged.connect(lambda name, bp: types.append((name, bp))) + try: + rows_before = model.rowCount() + for bp in _pm_broadcasts(stub_instrument.name): + model.updateParameter(bp) + assert locks == [("q01.IF", None)] + assert types == [("qubit", None)] + assert model.rowCount() == rows_before + finally: + model.stopListener() + + +def test_state_on_construction_holds_types_and_locks_created_before( + qtbot, pm, server_port +): + """Types and Locks that exist before the GUI is built are in + gui.state after the constructor ran.""" + pm.add_parameter("cq01.x", initial_value=1.0, unit="Hz") + pm.add_parameter("cq02.x", initial_value=2.0, unit="Hz") + pm.lock("cq02.x", "cq01.x") + pm.add_type("cqubit") + pm.add_type_parameter("cqubit", "IF", default=3.0, unit="Hz") + + gui = _make_gui(qtbot, pm, server_port) + try: + qtbot.waitUntil( + lambda: isinstance(gui.state.types.get("cqubit"), PMTypeBluePrint), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("cq02.x") + == PMLockBluePrint(target=f"{PM_NAME}.cq01.x", locked=True) + ), + timeout=BROADCAST_TIMEOUT, + ) + assert gui.state.types["cqubit"].parameters["IF"] == { + "default": 3.0, + "unit": "Hz", + "target": None, + } + finally: + gui.model.stopListener() + + +def test_lock_broadcasts_from_a_second_client_update_the_state( + qtbot, pm, second_client, server_port +): + """A second Client's lock, unlock and remove_lock reach gui.state + through the pm-lock-update Broadcasts.""" + second_pm = _second_parameter_manager(second_client) + second_pm.add_parameter("q01.x", initial_value=10.0, unit="Hz") + second_pm.add_parameter("q02.x", initial_value=20.0, unit="Hz") + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.lock("q02.x", "q01.x") + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("q02.x") + == PMLockBluePrint(target=f"{PM_NAME}.q01.x", locked=True) + ), + timeout=BROADCAST_TIMEOUT, + ) + + second_pm.unlock("q02.x") + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("q02.x") + == PMLockBluePrint(target=f"{PM_NAME}.q01.x", locked=False) + ), + timeout=BROADCAST_TIMEOUT, + ) + + second_pm.remove_lock("q02.x") + qtbot.waitUntil( + lambda: "q02.x" not in gui.state.locks, + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_type_broadcasts_from_a_second_client_update_the_state( + qtbot, pm, second_client, server_port +): + """A second Client's add_type, add_type_parameter and remove_type + reach gui.state through the pm-type-update Broadcasts.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.add_type("qubit") + qtbot.waitUntil( + lambda: isinstance(gui.state.types.get("qubit"), PMTypeBluePrint), + timeout=BROADCAST_TIMEOUT, + ) + + second_pm.add_type_parameter("qubit", "IF", unit="Hz") + qtbot.waitUntil( + lambda: ( + gui.state.types.get("qubit") is not None + and "IF" in gui.state.types["qubit"].parameters + ), + timeout=BROADCAST_TIMEOUT, + ) + assert gui.state.types["qubit"].parameters["IF"] == { + "default": None, + "unit": "Hz", + "target": None, + } + + second_pm.remove_type("qubit") + qtbot.waitUntil( + lambda: "qubit" not in gui.state.types, + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_load_profile_refreshes_the_state_without_broadcasts( + qtbot, pm, second_client, server_port +): + """loadProfile re-reads the Types and Locks from the Parameter Manager + even while the listener is stopped, so no Broadcast can fill the + state: a Type the second Client created before the load must be in + gui.state afterwards.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.model.stopListener() + second_pm.add_type("pt") + assert "pt" not in gui.state.types # no listener, no Broadcast + + gui.loadProfile() + assert "pt" in gui.state.types + finally: + gui.model.stopListener() + + +def test_type_lock_from_a_second_client_updates_types_and_locks( + qtbot, pm, second_client, server_port +): + """A second Client's lock_type_parameter puts the entry's Target into + gui.state.types and the Instances' Locks into gui.state.locks.""" + second_pm = _second_parameter_manager(second_client) + second_pm.add_parameter("dq01.IF", initial_value=1.0, unit="Hz") + second_pm.add_parameter("dq02.IF", initial_value=2.0, unit="Hz") + # a root-level parameter: the root is never an Instance, so targeting + # it cannot self-lock an Instance parameter + second_pm.add_parameter("tshared", initial_value=0.0, unit="Hz") + second_pm.add_type("dqubit") + second_pm.add_type_parameter("dqubit", "IF", default=1.0, unit="Hz") + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # an explicit Target, so no Globals parameter is created and no + # parameter-creation Broadcast hits the model's creation branch + second_pm.lock_type_parameter("dqubit", "IF", target="tshared") + + qtbot.waitUntil( + lambda: ( + gui.state.types.get("dqubit") is not None + and gui.state.types["dqubit"].parameters["IF"]["target"] + == f"{PM_NAME}.tshared" + ), + timeout=BROADCAST_TIMEOUT, + ) + for follower in ("dq01.IF", "dq02.IF"): + qtbot.waitUntil( + lambda follower=follower: ( + gui.state.locks.get(follower) + == PMLockBluePrint(target=f"{PM_NAME}.tshared", locked=True) + ), + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_a_second_clients_set_reaches_the_tree_widget( + qtbot, pm, second_client, server_port +): + """A value the second Client sets on a parameter shown in the tree + ends up in the row's widget (D24: onItemNewValue uses + widget._setMethod).""" + pm.add_parameter("sq01.x", initial_value=1.0, unit="Hz") + pm.update() # the GUI's tree is built from the proxy's blueprint + + gui = _make_gui(qtbot, pm, server_port) + try: + second_pm = _second_parameter_manager(second_client) + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + widget = gui.view.delegate.parameters["sq01.x"] + line_edit = widget.paramWidget.input + assert line_edit.text() == "1.0" + + second_pm.sq01.x.set(42) + qtbot.waitUntil(lambda: line_edit.text() == "42", timeout=BROADCAST_TIMEOUT) + finally: + gui.model.stopListener() + + +def test_refresh_all_refills_the_state_from_the_server( + qtbot, pm, second_client, server_port +): + """refreshAll() re-reads every Type and Lock, so the state catches up + with changes made while the listener was not running.""" + second_pm = _second_parameter_manager(second_client) + second_pm.add_parameter("rq01.x", initial_value=1.0, unit="Hz") + second_pm.add_parameter("rq02.x", initial_value=2.0, unit="Hz") + + gui = _make_gui(qtbot, pm, server_port) + try: + # no live updates: the Lock must reach the state through the GUI's + # own refresh + gui.model.stopListener() + second_pm.lock("rq02.x", "rq01.x") + assert "rq02.x" not in gui.state.locks + + gui.refreshAll() + assert gui.state.locks == second_pm.list_locks() + assert gui.state.locks["rq02.x"] == PMLockBluePrint( + target=f"{PM_NAME}.rq01.x", locked=True + ) + finally: + gui.model.stopListener() + + +# --------------------------------------------------------------------------- +# plan task 5.2: tabs, tints and gutter bands +# --------------------------------------------------------------------------- + + +def _type_blueprint( + name, entries, nested=None, registry=None, defaults=None, targets=None +): + """A ``PMTypeBluePrint`` whose effective set is expanded the way + ``params.py`` expands it: the Type's own entries carry itself as + ``from_type``, and every Nested Type's effective set is mounted under + the submodule that requires it, keeping the defining Type.""" + nested = dict(nested or {}) + defaults = defaults or {} + targets = targets or {} + effective = { + path: {"unit": unit, "from_type": name} for path, unit in entries.items() + } + for submodule, nested_name in nested.items(): + for path, spec in registry[nested_name].effective.items(): + effective[f"{submodule}.{path}"] = dict(spec) + return PMTypeBluePrint( + name=name, + parameters={ + path: { + "default": defaults.get(path), + "unit": unit, + "target": targets.get(path), + } + for path, unit in entries.items() + }, + nested=nested, + effective=effective, + ) + + +def test_old_gui_instruments_path_still_serves_the_moved_names(): + """Station configs name the widget by its dotted path, which the Server + resolves with ``import_module`` and ``getattr``. The path from before + the Parameter Manager GUI moved to ``gui.parameter_manager`` keeps + resolving to the same objects; other names still fail.""" + old = importlib.import_module("instrumentserver.gui.instruments") + new = importlib.import_module("instrumentserver.gui.parameter_manager") + assert getattr(old, "ParameterManagerGui") is new.ParameterManagerGui + assert getattr(old, "PMState") is new.PMState + with pytest.raises(AttributeError): + getattr(old, "TypesPane") + + +def test_compute_claims_requires_every_path_with_the_declared_unit(): + """A submodule is an Instance only when it carries every effective path + of the Type with the unit the Type declares (D12).""" + qubit = _type_blueprint("qubit", {"IF": "Hz", "bw": "Hz"}) + + # both paths, both units: q01 matches and claims its rows + claims = compute_claims({"qubit": qubit}, {"q01.IF": "Hz", "q01.bw": "Hz"}) + assert claims["q01.IF"] == Claim(type="qubit", instance="q01", stack=["qubit"]) + assert claims["q01.bw"] == Claim(type="qubit", instance="q01", stack=["qubit"]) + assert claims["q01"] == Claim(type="qubit", instance="q01", stack=["qubit"]) + + # one path missing: no Instance, nothing claimed + assert compute_claims({"qubit": qubit}, {"q01.IF": "Hz"}) == {} + + # a wrong unit excludes the submodule just the same + assert compute_claims({"qubit": qubit}, {"q01.IF": "Hz", "q01.bw": "V"}) == {} + assert compute_claims({"qubit": qubit}, {"q01.IF": "V", "q01.bw": "Hz"}) == {} + + +def test_compute_claims_never_matches_globals_at_any_depth(): + """The Globals submodule and everything under it are excluded from + matching (D12), wherever ``_globals`` appears in the tree.""" + qubit = _type_blueprint("qubit", {"IF": "Hz"}) + claims = compute_claims( + {"qubit": qubit}, + { + "q01.IF": "Hz", + "_globals.qubit.IF": "Hz", + "q01._globals.IF": "Hz", + }, + ) + assert set(claims) == {"q01", "q01.IF"} + + +def test_compute_claims_never_matches_the_root(): + """The root of the Parameter Manager is never an Instance (D12): a + parameter at the root carries the shape, but claims nothing.""" + qubit = _type_blueprint("qubit", {"IF": "Hz"}) + claims = compute_claims({"qubit": qubit}, {"IF": "Hz", "q01.IF": "Hz"}) + assert set(claims) == {"q01", "q01.IF"} + + +def test_compute_claims_of_an_empty_type(): + """An empty Type has no Instances (D12) and claims nothing.""" + qubit = _type_blueprint("qubit", {}) + assert compute_claims({"qubit": qubit}, {"q01.IF": "Hz"}) == {} + + +def test_compute_claims_innermost_nested_type_wins(): + """A Nested Type claims the rows it defines at and below its submodule + (the mock's owner credit): ``readout`` claims ``q01.readout.bw`` with + the ``qubit`` behind it in the stack.""" + readout = _type_blueprint("readout", {"bw": "Hz"}) + qubit = _type_blueprint( + "qubit", + {"IF": "Hz"}, + nested={"readout": "readout"}, + registry={"readout": readout}, + ) + claims = compute_claims( + {"readout": readout, "qubit": qubit}, + {"q01.IF": "Hz", "q01.readout.bw": "Hz"}, + ) + assert claims["q01.IF"] == Claim(type="qubit", instance="q01", stack=["qubit"]) + assert claims["q01.readout.bw"] == Claim( + type="readout", instance="q01.readout", stack=["qubit", "readout"] + ) + # the Instance row of the Nested Type is claimed by it as well + assert claims["q01.readout"] == Claim( + type="readout", instance="q01.readout", stack=["qubit", "readout"] + ) + # the outer Instance row stays with the outer Type + assert claims["q01"] == Claim(type="qubit", instance="q01", stack=["qubit"]) + + +def test_compute_claims_of_a_nested_type_nested_two_levels_deep(): + """A Nested Type's own Nested Type extends the submodule chain (the + mock's ``at``): ``pulse_window`` claims at ``q01.readout.pw``.""" + pulse_window = _type_blueprint("pulse_window", {"win": "s"}) + readout = _type_blueprint( + "readout", + {"bw": "Hz"}, + nested={"pw": "pulse_window"}, + registry={"pulse_window": pulse_window}, + ) + qubit = _type_blueprint( + "qubit", + {"IF": "Hz"}, + nested={"readout": "readout"}, + registry={"readout": readout}, + ) + claims = compute_claims( + {"pulse_window": pulse_window, "readout": readout, "qubit": qubit}, + {"q01.IF": "Hz", "q01.readout.bw": "Hz", "q01.readout.pw.win": "s"}, + ) + assert claims["q01.readout.pw.win"] == Claim( + type="pulse_window", + instance="q01.readout.pw", + stack=["qubit", "readout", "pulse_window"], + ) + # the Nested Type's submodule row carries the same claim + assert claims["q01.readout.pw"] == Claim( + type="pulse_window", + instance="q01.readout.pw", + stack=["qubit", "readout", "pulse_window"], + ) + assert claims["q01.readout.bw"].type == "readout" + assert claims["q01.IF"].type == "qubit" + + +def test_compute_claims_larger_effective_set_wins(): + """Two non-nested Types covering the same rows: the larger effective + set claims them, both carry the row in the stack.""" + big = _type_blueprint("big", {"a": "Hz", "b": "Hz"}) + small = _type_blueprint("small", {"a": "Hz"}) + claims = compute_claims( + {"big": big, "small": small}, {"q01.a": "Hz", "q01.b": "Hz"} + ) + assert claims["q01.a"] == Claim(type="big", instance="q01", stack=["big", "small"]) + assert claims["q01.b"] == Claim(type="big", instance="q01", stack=["big"]) + assert claims["q01"] == Claim(type="big", instance="q01", stack=["big", "small"]) + + +def test_compute_claims_breaks_ties_by_type_name(): + """Two Types with the same Instance and the same effective set size: + the Type name decides, for determinism.""" + aaa = _type_blueprint("aaa", {"x": "Hz"}) + zzz = _type_blueprint("zzz", {"x": "Hz"}) + claims = compute_claims({"zzz": zzz, "aaa": aaa}, {"q01.x": "Hz"}) + assert claims["q01.x"] == Claim(type="aaa", instance="q01", stack=["aaa", "zzz"]) + + +def test_palette_assigns_slots_in_type_creation_order(): + """The five palette slots go to the first five Types in creation order, + one new Type after the other as the GUI sees them.""" + palette = TypePalette() + palette.sync(["qubit"]) + assert palette.slots == {"qubit": 0} + palette.sync(["qubit", "readout"]) + palette.sync(["qubit", "readout", "mixer", "attenuator", "script"]) + assert palette.slots == { + "qubit": 0, + "readout": 1, + "mixer": 2, + "attenuator": 3, + "script": 4, + } + + +def test_palette_recycles_slot_zero_when_exhausted(): + """A sixth Type reuses slot 0 when all five slots are taken (the mock's + freeTint recycles when exhausted).""" + palette = TypePalette() + palette.sync([f"type{index}" for index in range(5)]) + palette.sync([f"type{index}" for index in range(6)]) + assert palette.slots["type5"] == 0 + + +def test_palette_frees_the_slot_of_a_removed_type(): + """Removing a Type frees its slot and the next new Type takes the + lowest free slot.""" + palette = TypePalette() + palette.sync(["a", "b", "c", "d", "e"]) + palette.sync(["a", "b", "d", "e"]) # c was removed + palette.sync(["a", "b", "d", "e", "f"]) # a new Type arrives + assert palette.slots["f"] == 2 + + +def test_palette_keeps_the_slot_of_an_existing_type_across_updates(): + """A ``pm-type-update`` for an existing Type never changes its colour: + the slot survives the update and an order change in the state.""" + palette = TypePalette() + palette.sync(["a", "b", "c"]) + before = dict(palette.slots) + palette.sync(["a", "b", "c"]) # the update itself + palette.sync(["c", "b", "a"]) # ... and a reordered state + assert palette.slots == before + + +def test_the_parameters_view_moves_into_a_tab_widget(qtbot, pm, server_port): + """The existing widget becomes tab 0 ("Parameters") of a QTabWidget; + tab 1 ("Types") hosts the Types pane (plan task 5.5); the gutter + column is wired into the view: visual position 0, fixed width, its own + delegate reading the GUI's palette, tree branches on the name column.""" + gui = _make_gui(qtbot, pm, server_port) + try: + assert gui.tabs.count() == 2 + assert gui.tabs.tabText(0) == "Parameters" + assert gui.tabs.tabText(1) == "Types" + parameters_tab = gui.tabs.widget(0) + assert parameters_tab is gui.parametersTab + assert parameters_tab.isAncestorOf(gui.view) + types_tab = gui.tabs.widget(1) + assert types_tab is gui.typesTab + assert isinstance(gui.typesPane, QtWidgets.QWidget) + assert types_tab.isAncestorOf(gui.typesPane) + + header = gui.view.header() + assert header.visualIndex(GUTTER_COLUMN) == 0 + assert header.sectionSize(GUTTER_COLUMN) == GUTTER_WIDTH + assert isinstance(gui.view.itemDelegateForColumn(GUTTER_COLUMN), GutterDelegate) + assert gui.view.gutterDelegate.typePalette is gui.typePalette + assert gui.view.treePosition() == 0 + # the Lock column sits between the unit and the delegate column + # (visual order: gutter, name, unit, locked to, delegate), with a + # resizable default width and its header label + assert header.visualIndex(LOCK_COLUMN) == 3 + assert header.visualIndex(2) == 4 + assert header.sectionSize(LOCK_COLUMN) == LOCK_COLUMN_WIDTH + assert gui.model.horizontalHeaderItem(LOCK_COLUMN).text() == "locked to" + finally: + gui.model.stopListener() + + +def _row_items(gui, path): + """The five items of the row ``path``: name, unit, delegate, gutter and + Lock column.""" + matches = gui.model.findItems( + path, + QtCore.Qt.MatchFlag.MatchExactly | QtCore.Qt.MatchFlag.MatchRecursive, + 0, + ) + assert matches, f"no row {path!r} in the model" + item = matches[0] + parent = item.parent() + if parent is None: + return [gui.model.item(item.row(), column) for column in range(5)] + return [parent.child(item.row(), column) for column in range(5)] + + +def _lock_item(gui, path): + """The Lock column item of the row ``path``.""" + return _row_items(gui, path)[LOCK_COLUMN] + + +def _type_tint(gui, type_name): + """The (tint, tintAlt) pair of the Type's palette slot, or ``None`` + while the GUI has not assigned the Type a slot.""" + slot = gui.typePalette.slots.get(type_name) + if slot is None: + return None + entry = tint_colours()[slot] + return (entry["tint"], entry["tintAlt"]) + + +def test_tints_follow_a_second_clients_type(qtbot, pm, second_client, server_port): + """A Type the second Client adds tints the rows its Instances carry + (including the Instance row itself) and marks the gutter stack; when + its entries and then the Type are removed, the rows lose them again.""" + second_pm = _second_parameter_manager(second_client) + pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") + pm.add_parameter("q01.readout.bw", initial_value=2.0, unit="Hz") + pm.add_parameter("q02.IF", initial_value=3.0, unit="V") + pm.add_parameter("other.x", initial_value=4.0, unit="s") + pm.update() # the GUI's tree is built from the proxy's blueprint + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # no creation side effect: q01 already carries IF with the unit the + # entry declares, and no other submodule matches + second_pm.add_type("qubit") + second_pm.add_type_parameter("qubit", "IF", unit="Hz") + + qtbot.waitUntil( + lambda: ( + _type_tint(gui, "qubit") is not None + and _row_items(gui, "q01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "qubit") + ), + timeout=BROADCAST_TIMEOUT, + ) + tint = _type_tint(gui, "qubit") + for path in ("q01", "q01.IF"): + # every column of a claimed row carries the tint: name, unit, + # delegate and gutter + for item in _row_items(gui, path): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint + assert _row_items(gui, "q01.IF")[3].data(GUTTER_ROLE) == ["qubit"] + # a wrong unit and an unrelated row carry no background + for path in ("q02.IF", "other.x"): + for item in _row_items(gui, path): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) is None + + # q01 already carries readout.bw: no creation, and the row tints too + second_pm.add_type_parameter("qubit", "readout.bw", unit="Hz") + qtbot.waitUntil( + lambda: ( + _row_items(gui, "q01.readout.bw")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in tint + ), + timeout=BROADCAST_TIMEOUT, + ) + for item in _row_items(gui, "q01.readout.bw"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint + for item in _row_items(gui, "q01.IF"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint + + # emptying the Type takes the tint and the gutter band away again + second_pm.remove_type_parameter("qubit", "IF") + second_pm.remove_type_parameter("qubit", "readout.bw") + qtbot.waitUntil( + lambda: ( + _row_items(gui, "q01.IF")[0].data(QtCore.Qt.ItemDataRole.BackgroundRole) + is None + and _row_items(gui, "q01.IF")[3].data(GUTTER_ROLE) == [] + ), + timeout=BROADCAST_TIMEOUT, + ) + + # removing the Type clears the last tint and frees the palette slot + second_pm.remove_type("qubit") + qtbot.waitUntil( + lambda: ( + _row_items(gui, "q01.readout.bw")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + is None + and "qubit" not in gui.typePalette.slots + ), + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_refresh_all_recomputes_tints_after_a_model_reload( + qtbot, pm, second_client, server_port +): + """With the listener stopped, a Type the second Client adds still tints + the rows once refreshAll reloads the model and the state.""" + second_pm = _second_parameter_manager(second_client) + pm.add_parameter("eq01.IF", initial_value=1.0, unit="Hz") + pm.update() + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.model.stopListener() + # no creation side effect: eq01 already carries IF with the unit the + # entry declares + second_pm.add_type("equbit") + second_pm.add_type_parameter("equbit", "IF", unit="Hz") + assert ( + _row_items(gui, "eq01.IF")[0].data(QtCore.Qt.ItemDataRole.BackgroundRole) + is None + ) + + gui.refreshAll() + entry = tint_colours()[gui.typePalette.slots["equbit"]] + for item in _row_items(gui, "eq01.IF"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in ( + entry["tint"], + entry["tintAlt"], + ) + assert _row_items(gui, "eq01.IF")[3].data(GUTTER_ROLE) == ["equbit"] + finally: + gui.model.stopListener() + + +def test_tints_follow_a_switch_to_a_dark_theme(qtbot, pm, second_client, server_port): + """Switching the application to a dark palette re-tints the claimed + rows with the dark palette entry, and switching back restores the + light one.""" + second_pm = _second_parameter_manager(second_client) + pm.add_parameter("dq01.IF", initial_value=1.0, unit="Hz") + pm.update() + + app = QtWidgets.QApplication.instance() + original = app.palette() + # start from a light palette whatever theme the machine is in + light = QtGui.QPalette(original) + light.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor("#f0f0f0")) + light.setColor(QtGui.QPalette.ColorRole.WindowText, QtGui.QColor("#202020")) + app.setPalette(light) + gui = _make_gui(qtbot, pm, server_port) + try: + gui.model.stopListener() + second_pm.add_type("dqubit") + second_pm.add_type_parameter("dqubit", "IF", unit="Hz") + gui.refreshAll() + slot = gui.typePalette.slots["dqubit"] + assert not is_dark_theme() + light_entry = TINT_COLOURS[slot] + assert _row_items(gui, "dq01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) in (light_entry["tint"], light_entry["tintAlt"]) + + dark = QtGui.QPalette(light) + dark.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor("#202020")) + dark.setColor(QtGui.QPalette.ColorRole.WindowText, QtGui.QColor("#f0f0f0")) + app.setPalette(dark) + dark_entry = TINT_COLOURS_DARK[slot] + qtbot.waitUntil( + lambda: ( + _row_items(gui, "dq01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in (dark_entry["tint"], dark_entry["tintAlt"]) + ), + timeout=BROADCAST_TIMEOUT, + ) + assert gui.typePalette.bar_colour("dqubit") == dark_entry["bar"] + + app.setPalette(light) + qtbot.waitUntil( + lambda: ( + _row_items(gui, "dq01.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in (light_entry["tint"], light_entry["tintAlt"]) + ), + timeout=BROADCAST_TIMEOUT, + ) + finally: + app.setPalette(original) + gui.model.stopListener() + + +def test_a_deletion_broadcast_recomputes_the_tints( + qtbot, pm, second_client, server_port +): + """A parameter-deletion Broadcast from a second Client removes the row + and recomputes the tints: the submodule that stops carrying the whole + set loses its Claim, so the surviving rows show no tint and no gutter + band. Deletion is safe live (the model's deletion branch touches no + Proxy blueprint), and creation is too since plan task 5.5 fixed the + creation branch.""" + second_pm = _second_parameter_manager(second_client) + pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") + pm.add_parameter("q01.bw", initial_value=2.0, unit="Hz") + pm.update() # the GUI's tree is built from the proxy's blueprint + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # no creation side effect: q01 already carries IF and bw with the + # units the entries declare + second_pm.add_type("qubit") + second_pm.add_type_parameter("qubit", "IF", unit="Hz") + second_pm.add_type_parameter("qubit", "bw", unit="Hz") + + qtbot.waitUntil( + lambda: ( + _type_tint(gui, "qubit") is not None + and _row_items(gui, "q01.bw")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "qubit") + ), + timeout=BROADCAST_TIMEOUT, + ) + tint = _type_tint(gui, "qubit") + for item in _row_items(gui, "q01.IF"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint + + # removing the parameter makes q01 stop matching, so the whole Type + # claim is gone: the row is removed and the survivors untint + second_pm.remove_parameter("q01.bw") + + def _q01_bw_gone_and_q01_untinted(): + matches = gui.model.findItems( + "q01.bw", + QtCore.Qt.MatchFlag.MatchExactly | QtCore.Qt.MatchFlag.MatchRecursive, + 0, + ) + if matches: + return False + items = _row_items(gui, "q01.IF") + return ( + all( + item.data(QtCore.Qt.ItemDataRole.BackgroundRole) is None + for item in items + ) + and items[3].data(GUTTER_ROLE) == [] + ) + + qtbot.waitUntil(_q01_bw_gone_and_q01_untinted, timeout=BROADCAST_TIMEOUT) + for item in _row_items(gui, "q01"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) is None + finally: + gui.model.stopListener() + + +# --------------------------------------------------------------------------- +# plan task 5.3: Lock column, toggle, context menu, arm strip +# --------------------------------------------------------------------------- + + +def test_relative_path_strips_the_instrument_name(): + """Lock Targets are stored as full dotted paths; every string the GUI + shows is relative to the Parameter Manager.""" + assert relative_path(f"{PM_NAME}.q01.IF", PM_NAME) == "q01.IF" + # a path without the prefix is returned unchanged + assert relative_path("q01.IF", PM_NAME) == "q01.IF" + + +def test_lock_column_text_shows_the_three_forms(): + """A Follower shows ``locked to `` or ``unlocked · `` + (relative Target, middle dot); a Target of N Locks — locked and + unlocked alike — shows ``target ×N``; everything else shows nothing.""" + locks = { + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=False), + } + assert lock_column_text("q02.IF", locks, PM_NAME) == "locked to q01.IF" + assert lock_column_text("q03.IF", locks, PM_NAME) == "unlocked · q01.IF" + # the Target note counts unlocked Locks too + assert lock_column_text("q01.IF", locks, PM_NAME) == "target ×2" + assert lock_column_text("other.x", locks, PM_NAME) == "" + assert lock_column_text("q01", locks, PM_NAME) == "" + + +def test_lock_column_text_follower_text_wins_over_the_target_note(): + """A row that is both Follower and Target shows its own Lock state, + like the mock's ``rec.lockedTo || srcNote(p)``.""" + locks = { + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + "q01.IF": PMLockBluePrint(target=f"{PM_NAME}.other.x", locked=False), + } + assert lock_column_text("q01.IF", locks, PM_NAME) == "unlocked · other.x" + + +def test_followers_reaching_walks_locked_chains(): + """Every Follower whose locked Lock targets the parameter directly or + over a chain of locked Locks; the direct Follower and the middle hop + both reach ``q01.IF``.""" + locks = { + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + } + assert sorted(followers_reaching("q01.IF", locks, PM_NAME)) == [ + "q02.IF", + "q03.IF", + ] + assert followers_reaching("q02.IF", locks, PM_NAME) == ["q03.IF"] + assert followers_reaching("q03.IF", locks, PM_NAME) == [] + + +def test_an_unlocked_middle_hop_stops_the_chain(): + """An unlocked Lock answers ``get`` with its own value (D7), so the + Followers behind it do not see an update made past it.""" + locks = { + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=False), + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + } + assert followers_reaching("q01.IF", locks, PM_NAME) == [] + assert followers_reaching("q02.IF", locks, PM_NAME) == ["q03.IF"] + + +def test_a_cycle_in_the_locks_does_not_loop(): + """A Lock mapping that holds a cycle terminates the walk.""" + locks = { + "q01.IF": PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + } + assert sorted(followers_reaching("q01.IF", locks, PM_NAME)) == [ + "q01.IF", + "q02.IF", + ] + + +def test_rank_lock_targets_orders_like_the_mock(): + """Rank 0 (same relative path inside its Instance) before rank 1 (the + relative path occurs as a submodule) before rank 2, alphabetical + within a rank, the Follower itself excluded.""" + claims = { + "q01.IF": Claim(type="qubit", instance="q01", stack=["qubit"]), + "q02.IF": Claim(type="qubit", instance="q02", stack=["qubit"]), + } + candidates = ["q01.IF", "q05.IF.gain", "other.x", "other.a", "q02.IF"] + ranked = rank_lock_targets("q01.IF", candidates, claims) + assert ranked == ["q02.IF", "q05.IF.gain", "other.a", "other.x"] + + +def test_rank_lock_targets_without_a_claim_is_alphabetical(): + """A Follower claimed by no Type has no relative path to prefer, so + every candidate is rank 2 and sorts alphabetically.""" + ranked = rank_lock_targets("q01.IF", ["zz.x", "aa.x", "q01.IF"], {}) + assert ranked == ["aa.x", "zz.x"] + + +def test_lock_arm_strip_picks_cancels_and_shows_errors(qtbot): + """The arm strip picks with Return (the exact path, or the first + completion the completer filters for the typed text), cancels with + Escape and the Cancel button, shows the error text, and disarms.""" + strip = LockArmStrip() + qtbot.addWidget(strip) + picked = [] + strip.targetPicked.connect(picked.append) + cancelled = [] + strip.cancelled.connect(lambda: cancelled.append(True)) + + strip.arm("q01.IF", ["q02.IF", "q03.IF"]) + assert strip.label.text() == "Target for q01.IF" + assert strip.completerModel.stringList() == ["q02.IF", "q03.IF"] + assert not strip.isHidden() + + strip.lineEdit.setText("q02.IF") + qtbot.keyClick(strip.lineEdit, QtCore.Qt.Key.Key_Return) + assert picked == ["q02.IF"] + + # the completer popup's pick path (the activated signal) emits too + strip.arm("q01.IF", ["q02.IF", "q03.IF"]) + strip.completer.activated[str].emit("q03.IF") + assert picked == ["q02.IF", "q03.IF"] + + # a text that matches no candidate picks nothing (no unrelated Target) + strip.arm("q01.IF", ["q02.IF", "q03.IF"]) + strip.lineEdit.setText("garbage") + qtbot.keyClick(strip.lineEdit, QtCore.Qt.Key.Key_Return) + assert picked == ["q02.IF", "q03.IF"] + + # a partial text that matches picks the first filtered completion + strip.arm("q01.IF", ["q02.IF", "q03.IF"]) + strip.lineEdit.setText("q03") + qtbot.keyClick(strip.lineEdit, QtCore.Qt.Key.Key_Return) + assert picked == ["q02.IF", "q03.IF", "q03.IF"] + + # the error label shows the Server's text until the next disarm + strip.show_error("cycle: cannot lock q02.IF to q01.IF") + assert strip.errorLabel.text() == "cycle: cannot lock q02.IF to q01.IF" + assert not strip.errorLabel.isHidden() + + # Escape cancels while the strip has focus; the strip only emits the + # signal — hiding on cancel is the GUI's cancel_arm + strip.show() + qtbot.waitExposed(strip) + strip.activateWindow() + strip.lineEdit.setFocus() + qtbot.wait(20) + qtbot.keyClick(strip.lineEdit, QtCore.Qt.Key.Key_Escape) + assert cancelled == [True] + + # so does the Cancel button, and disarming hides and clears the strip + strip.arm("q01.IF", ["q02.IF"]) + strip.show_error("cycle: cannot lock q02.IF to q01.IF") + strip.cancelButton.click() + assert cancelled == [True, True] + strip.disarm() + assert strip.isHidden() + assert strip.errorLabel.isHidden() and strip.errorLabel.text() == "" + + +def test_parameter_widget_set_read_only(qtbot): + """set_read_only disables the input and the set button and keeps the + get button enabled, and stores the flag.""" + from instrumentserver.params import ParameterManager + + manager = ParameterManager("pw_read_only_local") + manager.add_parameter("x", initial_value=1.0) + widget = ParameterWidget(manager.parameter("x")) + qtbot.addWidget(widget) + + assert widget.read_only is False + widget.set_read_only(True) + assert widget.read_only is True + assert not widget.paramWidget.isEnabled() + assert not widget.setButton.isEnabled() + assert widget.getButton.isEnabled() + + widget.set_read_only(False) + assert widget.read_only is False + assert widget.paramWidget.isEnabled() + assert widget.setButton.isEnabled() + assert widget.getButton.isEnabled() + + +def _make_live_parameters(pm): + """Create the 5.3 live tests' parameters before the GUI is built. + (Since the 5.5 creation-branch fix they could equally be created + while the GUI is open; the 5.3 tests keep their original order.)""" + pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") + pm.add_parameter("q02.IF", initial_value=2.0, unit="Hz") + pm.add_parameter("q03.IF", initial_value=3.0, unit="Hz") + pm.add_parameter("other.x", initial_value=4.0, unit="s") + pm.update() # the GUI's tree is built from the proxy's blueprint + + +def _click_row(qtbot, gui, path): + """Click the tree row ``path`` with the left mouse button; the GUI must + be shown for the view to have geometry.""" + gui.show() + qtbot.waitExposed(gui) + gui.view.expandAll() + source_index = gui.model.indexFromItem(_row_items(gui, path)[0]) + proxy_index = gui.proxyModel.mapFromSource(source_index) + qtbot.mouseClick( + gui.view.viewport(), + QtCore.Qt.MouseButton.LeftButton, + pos=gui.view.visualRect(proxy_index).center(), + ) + + +def test_arm_via_context_menu_pick_a_row_and_toggle( + qtbot, pm, second_client, server_port +): + """The plan's named flow: arm through the context menu, pick a row to + lock, watch the Lock column and the lock button repaint through the + Broadcast, toggle with the lock button both ways, and unlock through + the context menu.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + gui.view.lastSelectedItem = _row_items(gui, "q01.IF")[0] + gui.view.lockToAction.trigger() + + assert not gui.armStrip.isHidden() + assert gui.armStrip.label.text() == "Target for q01.IF" + assert gui.locksController.armed_follower == "q01.IF" + candidates = gui.armStrip.completerModel.stringList() + assert "q02.IF" in candidates + assert "q01.IF" not in candidates + + # clicking the q02.IF row picks it as the Target + _click_row(qtbot, gui, "q02.IF") + qtbot.waitUntil( + lambda: ( + pm.get_lock("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + ), + timeout=BROADCAST_TIMEOUT, + ) + assert gui.armStrip.isHidden() + assert gui.locksController.armed_follower is None + + # the pm-lock-update Broadcast repaints the Lock column and the + # lock button, and renders the locked Follower read-only + qtbot.waitUntil( + lambda: _lock_item(gui, "q01.IF").text() == "locked to q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + assert _lock_item(gui, "q02.IF").text() == "target ×1" + + follower_widget = gui.view.delegate.parameters["q01.IF"] + button = follower_widget.lockButton + assert not button.isHidden() + assert button.property("locked") is True + assert not follower_widget.paramWidget.isEnabled() + + # locking repaints the Follower's value: while locked it answers + # get with the Target's value (D3) + qtbot.waitUntil( + lambda: follower_widget._getMethod() == 2.0, + timeout=BROADCAST_TIMEOUT, + ) + + # toggle with the lock button: unlock first … + button.click() + qtbot.waitUntil( + lambda: pm.get_lock("q01.IF").locked is False, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: _lock_item(gui, "q01.IF").text() == "unlocked · q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + assert button.property("locked") is False + assert follower_widget.paramWidget.isEnabled() + + # unlocking exposes the Follower's own value again (D3, D5) + qtbot.waitUntil( + lambda: follower_widget._getMethod() == 1.0, + timeout=BROADCAST_TIMEOUT, + ) + + # … and lock again to the remembered Target + button.click() + qtbot.waitUntil( + lambda: pm.get_lock("q01.IF").locked is True, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: _lock_item(gui, "q01.IF").text() == "locked to q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + assert button.property("locked") is True + qtbot.waitUntil( + lambda: follower_widget._getMethod() == 2.0, + timeout=BROADCAST_TIMEOUT, + ) + + # the context menu's Unlock unlocks on the Server + gui.view.lastSelectedItem = _row_items(gui, "q01.IF")[0] + gui.view.unlockAction.trigger() + qtbot.waitUntil( + lambda: pm.get_lock("q01.IF").locked is False, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: _lock_item(gui, "q01.IF").text() == "unlocked · q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_a_cycle_attempt_shows_the_error_and_stays_armed( + qtbot, pm, second_client, server_port +): + """Locking the Follower's own Target back would close a cycle (D7): + the Server refuses, the arm strip shows the error text and stays + armed, and Escape disarms it.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + pm.lock("q01.IF", "q02.IF") + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + ), + timeout=BROADCAST_TIMEOUT, + ) + + gui.locksController.arm_lock("q02.IF") + assert gui.locksController.armed_follower == "q02.IF" + assert not gui.armStrip.isHidden() + + gui.locksController.pick_lock_target("q01.IF") + assert "cycle" in gui.armStrip.errorLabel.text() + assert not gui.armStrip.isHidden() + assert gui.locksController.armed_follower == "q02.IF" + assert pm.get_lock("q02.IF") is None + + # Escape over the tree disarms the pick too (the view's Escape + # shortcut calls cancel_arm) + gui.show() + qtbot.waitExposed(gui) + gui.view.setFocus() + qtbot.wait(20) + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_Escape) + assert gui.armStrip.isHidden() + assert gui.locksController.armed_follower is None + + # re-arm: Escape in the strip's line edit disarms as well + gui.locksController.arm_lock("q02.IF") + assert gui.locksController.armed_follower == "q02.IF" + gui.armStrip.activateWindow() + gui.armStrip.lineEdit.setFocus() + qtbot.wait(20) + qtbot.keyClick(gui.armStrip.lineEdit, QtCore.Qt.Key.Key_Escape) + assert gui.armStrip.isHidden() + assert gui.locksController.armed_follower is None + finally: + gui.model.stopListener() + + +def test_setting_the_target_from_a_second_client_repaints_the_followers( + qtbot, pm, second_client, server_port +): + """A value the second Client sets on a Target repaints every Follower + whose locked Lock chain reaches it (D3: a locked Follower answers get + with the Target's value, and nothing is ever pushed into it).""" + _make_live_parameters(pm) + # the second Client's proxy is built after the parameters exist, so its + # blueprint knows q02.IF + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # a chain: q03.IF follows q01.IF, which follows q02.IF + pm.lock("q01.IF", "q02.IF") + pm.lock("q03.IF", "q01.IF") + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + and gui.state.locks.get("q03.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True) + ), + timeout=BROADCAST_TIMEOUT, + ) + + second_pm.q02.IF.set(7) + follower_widget = gui.view.delegate.parameters["q01.IF"] + chain_widget = gui.view.delegate.parameters["q03.IF"] + qtbot.waitUntil( + lambda: follower_widget._getMethod() == 7, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: chain_widget._getMethod() == 7, + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_a_second_clients_lock_shows_in_the_column_and_button( + qtbot, pm, second_client, server_port +): + """A Lock the second Client makes shows up in the Lock column and on + the lock button without any GUI action.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + assert _lock_item(gui, "q03.IF").text() == "" + + second_pm.lock("q03.IF", "q02.IF") + qtbot.waitUntil( + lambda: _lock_item(gui, "q03.IF").text() == "locked to q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + widget = gui.view.delegate.parameters["q03.IF"] + assert not widget.lockButton.isHidden() + assert widget.lockButton.property("locked") is True + assert not widget.paramWidget.isEnabled() + finally: + gui.model.stopListener() + + +def test_the_context_menu_lock_actions_enable_by_the_lock_state( + qtbot, pm, second_client, server_port +): + """The aboutToShow rule: "Lock to…" is enabled for every parameter + row, "Unlock" only while that row's Lock in the state is locked, and + both are disabled on a submodule row.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.lock("q03.IF", "q02.IF") + qtbot.waitUntil( + lambda: _lock_item(gui, "q03.IF").text() == "locked to q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + + # (a) a parameter row with a locked Lock: both enabled + gui.view.lastSelectedItem = _row_items(gui, "q03.IF")[0] + gui.view.contextMenu.aboutToShow.emit() + assert gui.view.lockToAction.isEnabled() + assert gui.view.unlockAction.isEnabled() + + # (b) the same row after the second Client unlocks: only "Lock + # to…" stays enabled + second_pm.unlock("q03.IF") + qtbot.waitUntil( + lambda: _lock_item(gui, "q03.IF").text() == "unlocked · q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + gui.view.contextMenu.aboutToShow.emit() + assert gui.view.lockToAction.isEnabled() + assert not gui.view.unlockAction.isEnabled() + + # (c) a submodule row: both disabled + gui.view.lastSelectedItem = _row_items(gui, "q03")[0] + gui.view.contextMenu.aboutToShow.emit() + assert not gui.view.lockToAction.isEnabled() + assert not gui.view.unlockAction.isEnabled() + finally: + gui.model.stopListener() + + +def test_refresh_all_shows_a_lock_made_while_the_listener_was_stopped( + qtbot, pm, second_client, server_port +): + """With the listener stopped, a Lock the second Client makes still + reaches the Lock column and the lock button through refreshAll's + re-read of the state.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.model.stopListener() + second_pm.lock("q03.IF", "q02.IF") + assert _lock_item(gui, "q03.IF").text() == "" + + gui.refreshAll() + assert _lock_item(gui, "q03.IF").text() == "locked to q02.IF" + widget = gui.view.delegate.parameters["q03.IF"] + assert not widget.lockButton.isHidden() + assert widget.lockButton.property("locked") is True + assert not widget.paramWidget.isEnabled() + finally: + gui.model.stopListener() + + +def test_a_filter_cycle_re_applies_the_lock_state( + qtbot, pm, second_client, server_port +): + """A filter cycle re-opens the hidden rows' persistent editors as + fresh ParameterWidgets; apply_locks runs on filterFinished, so a + locked Follower comes back with its lock button and read-only input.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.lock("q01.IF", "q02.IF") + qtbot.waitUntil( + lambda: _lock_item(gui, "q01.IF").text() == "locked to q02.IF", + timeout=BROADCAST_TIMEOUT, + ) + + def _q01_if_is_mapped(mapped: bool): + matches = gui.model.findItems( + "q01.IF", + QtCore.Qt.MatchFlag.MatchExactly | QtCore.Qt.MatchFlag.MatchRecursive, + 0, + ) + proxy_index = gui.proxyModel.mapFromSource( + gui.model.indexFromItem(matches[0]) + ) + return proxy_index.isValid() is mapped + + # the filter hides the q01.IF row … + gui.lineEdit.setText("other") + qtbot.waitUntil(lambda: _q01_if_is_mapped(False), timeout=BROADCAST_TIMEOUT) + + # … and clearing it brings the row back with its Lock state + gui.lineEdit.setText("") + widget = gui.view.delegate.parameters["q01.IF"] + qtbot.waitUntil( + lambda: ( + widget.lockButton.property("locked") is True + and not widget.lockButton.isHidden() + ), + timeout=BROADCAST_TIMEOUT, + ) + assert not widget.paramWidget.isEnabled() + finally: + gui.model.stopListener() + + +# --------------------------------------------------------------------------- +# plan task 5.4: the Locks panel +# --------------------------------------------------------------------------- + + +def test_build_lock_rows_nests_followers_under_a_plain_target(): + """A plain Target with two Followers — one of them unlocked — builds + one root with two children, each child carrying its own Lock (locked + and unlocked alike, D5), and the root carrying none.""" + locks = { + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=False), + } + rows = build_lock_rows(locks, {}, PM_NAME) + assert [row.path for row in rows] == ["q01.IF"] + root = rows[0] + assert root.lock is None + assert root.type_locks == [] + assert [child.path for child in root.children] == ["q02.IF", "q03.IF"] + assert root.children[0].lock == locks["q02.IF"] + assert root.children[1].lock == locks["q03.IF"] + assert root.children[0].children == [] + + +def test_build_lock_rows_walks_a_chain_nested_and_once(): + """A chain q03 → q02 → q01 nests two levels deep and the middle hop + q02 appears once.""" + locks = { + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + } + rows = build_lock_rows(locks, {}, PM_NAME) + assert [row.path for row in rows] == ["q01.IF"] + root = rows[0] + assert [child.path for child in root.children] == ["q02.IF"] + assert [grandchild.path for grandchild in root.children[0].children] == ["q03.IF"] + + +def test_build_lock_rows_sorts_the_type_lock_target_first(): + """A Type Lock Target sorts before a plain Target and carries the + (Type, entry) pairs whose stored Target it is.""" + dqubit = PMTypeBluePrint( + name="dqubit", + parameters={ + "IF": { + "default": None, + "unit": "Hz", + "target": f"{PM_NAME}.tshared", + } + }, + nested={}, + effective={"IF": {"unit": "Hz", "from_type": "dqubit"}}, + ) + locks = { + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.plain.x", locked=True), + "dq01.IF": PMLockBluePrint(target=f"{PM_NAME}.tshared", locked=True), + } + rows = build_lock_rows(locks, {"dqubit": dqubit}, PM_NAME) + assert [row.path for row in rows] == ["tshared", "plain.x"] + assert rows[0].type_locks == [("dqubit", "IF")] + assert rows[1].type_locks == [] + assert [child.path for child in rows[0].children] == ["dq01.IF"] + + +def test_lock_root_follows_locked_hops_only(): + """lock_root walks locked Locks to the end of the chain and stops at + an unlocked hop, which answers ``get`` with its own value (D7).""" + locks = { + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=False), + } + assert lock_root("q03.IF", locks, PM_NAME) == "q02.IF" + assert lock_root("q02.IF", locks, PM_NAME) == "q02.IF" + assert lock_root("q01.IF", locks, PM_NAME) == "q01.IF" + + all_locked = { + "q03.IF": PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True), + "q02.IF": PMLockBluePrint(target=f"{PM_NAME}.q01.IF", locked=True), + } + assert lock_root("q03.IF", all_locked, PM_NAME) == "q01.IF" + + +def _panel_row_items(gui, path): + """The three items of the Locks panel row ``path``: label, value and + buttons.""" + matches = [] + + def walk(parent): + for row in range(parent.rowCount()): + item = parent.child(row, 0) + if item is None: + continue + if item.data(LOCK_ROW_ROLE) == path: + matches.append([parent.child(row, column) for column in range(3)]) + walk(item) + + walk(gui.locksPanel.model.invisibleRootItem()) + assert matches, f"no Locks panel row {path!r}" + return matches[0] + + +def _panel_root_paths(gui): + """The paths of the Locks panel's depth-0 rows.""" + root = gui.locksPanel.model.invisibleRootItem() + return [root.child(row, 0).data(LOCK_ROW_ROLE) for row in range(root.rowCount())] + + +def _panel_child_paths(gui, path): + """The paths of the Locks panel row ``path``'s children.""" + item = _panel_row_items(gui, path)[0] + return [item.child(row, 0).data(LOCK_ROW_ROLE) for row in range(item.rowCount())] + + +def test_the_tree_takes_the_spare_height_and_the_add_strip_sits_at_the_bottom( + qtbot, pm, server_port +): + """The tree (inside the Locks splitter) grows with the window, and the + add-parameter strip keeps its own height at the bottom of the + Parameters tab instead of sharing the spare height with the tree.""" + gui = _make_gui(qtbot, pm, server_port) + try: + gui.resize(900, 800) + gui.show() + qtbot.waitExposed(gui) + tab = gui.parametersTab + add_strip = gui.addParam + assert add_strip.height() == add_strip.sizeHint().height() + margin = tab.layout().contentsMargins().bottom() + assert tab.height() - add_strip.geometry().bottom() - 1 == margin + # nothing but the layout spacing between the tree and the strip + spacing = tab.layout().spacing() + assert ( + add_strip.geometry().top() - gui.locksSplitter.geometry().bottom() - 1 + == spacing + ) + finally: + gui.model.stopListener() + + +def _drag_header_edge(header, column, dx): + """Drag the right edge of ``column`` on ``header`` by ``dx`` pixels + with the left mouse button, the way a user resizes a column. (Qt's + test mouseMove does not carry a held button, so the events are sent + directly.)""" + edge = header.sectionViewportPosition(column) + header.sectionSize(column) - 1 + y = header.height() // 2 + left = QtCore.Qt.MouseButton.LeftButton + for kind, x, buttons in [ + (QtCore.QEvent.Type.MouseButtonPress, edge, left), + (QtCore.QEvent.Type.MouseMove, edge + dx, left), + ( + QtCore.QEvent.Type.MouseButtonRelease, + edge + dx, + QtCore.Qt.MouseButton.NoButton, + ), + ]: + event = QtGui.QMouseEvent( + kind, + QtCore.QPointF(x, y), + left, + buttons, + QtCore.Qt.KeyboardModifier.NoModifier, + ) + QtWidgets.QApplication.sendEvent(header.viewport(), event) + + +def test_the_panel_columns_can_be_resized_and_the_value_fits(qtbot, pm, server_port): + """The Locks panel's columns used to be fixed: no header edge could be + dragged, and the Target's value editor was squeezed until its number + was unreadable. Dragging the locks column's edge moves width between + the names and the values, the buttons column can be dragged too, and + the value column gives the editor at least the width it asks for.""" + _make_live_parameters(pm) + pm.lock("q01.IF", "q02.IF") + pm.update() + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.resize(1200, 800) + gui.show() + qtbot.waitExposed(gui) + gui.locksAction.trigger() + header = gui.locksPanel.view.header() + + def _value_fits(): + # re-read the editor: a panel rebuild replaces it + editor = gui.locksPanel.rowWidgets["q02.IF"]["editor"] + return header.sectionSize(1) >= editor.sizeHint().width() + + # the panel lays its columns out once it has been shown + qtbot.waitUntil(_value_fits, timeout=BROADCAST_TIMEOUT) + + name_width, value_width = header.sectionSize(0), header.sectionSize(1) + _drag_header_edge(header, 0, -60) + assert header.sectionSize(0) == name_width - 60 + assert header.sectionSize(1) == value_width + 60 + + buttons_width = header.sectionSize(2) + _drag_header_edge(header, 2, -20) + assert header.sectionSize(2) == buttons_width - 20 + finally: + gui.model.stopListener() + + +def test_the_locks_action_toggles_the_panel(qtbot, pm, server_port): + """The toolbar action is checkable and unchecked, the panel starts + hidden as the splitter's second pane, and the shortcut is registered; + triggering the action shows the panel.""" + gui = _make_gui(qtbot, pm, server_port) + try: + assert gui.locksAction.isCheckable() + assert not gui.locksAction.isChecked() + assert gui.locksPanel.isHidden() + assert gui.locksSplitter.widget(0) is gui.view + assert gui.locksSplitter.widget(1) is gui.locksPanel + assert gui.parametersTab.isAncestorOf(gui.locksSplitter) + assert KeyboardShortcutManager.REGISTRY["toggle_locks"] == ( + "Ctrl+Shift+L", + "Show or hide the Locks panel", + ) + + gui.locksAction.trigger() + assert gui.locksAction.isChecked() + assert not gui.locksPanel.isHidden() + + gui.locksAction.trigger() + assert not gui.locksAction.isChecked() + assert gui.locksPanel.isHidden() + finally: + gui.model.stopListener() + + +def test_the_panel_rows_reflect_list_locks_and_remove_from_the_panel( + qtbot, pm, second_client, server_port +): + """The plan's named tests: a second Client's locked chain shows in the + panel as one root with its Followers beneath; the remove button removes + the Lock on the Server and the row disappears; a Lock the second Client + makes while the panel is open appears live; and the second Client + unlocking a Follower flips its toggle and gives it its editor back.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + second_pm.lock("q01.IF", "q02.IF") + second_pm.lock("q03.IF", "q01.IF") + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + assert gui.state.locks == pm.list_locks() + + gui.locksAction.trigger() # shows the panel and rebuilds its rows + qtbot.waitUntil( + lambda: _panel_root_paths(gui) == ["q02.IF"], + timeout=BROADCAST_TIMEOUT, + ) + assert _panel_child_paths(gui, "q02.IF") == ["q01.IF"] + assert _panel_child_paths(gui, "q01.IF") == ["q03.IF"] + + # the root is a plain Target: no buttons and a ParameterWidget + # value editor; its Followers are locked: a read-only label and the + # toggle and remove buttons + root_entry = gui.locksPanel.rowWidgets["q02.IF"] + assert isinstance(root_entry["editor"], ParameterWidget) + assert root_entry["toggle"] is None and root_entry["remove"] is None + follower_entry = gui.locksPanel.rowWidgets["q01.IF"] + assert follower_entry["label"] is not None + assert follower_entry["editor"] is None + assert follower_entry["toggle"] is not None + assert follower_entry["remove"] is not None + grandchild_entry = gui.locksPanel.rowWidgets["q03.IF"] + assert grandchild_entry["label"] is not None + + # press the remove button of q03.IF: the Lock goes on the Server + gui.locksPanel.rowWidgets["q03.IF"]["remove"].click() + qtbot.waitUntil( + lambda: pm.get_lock("q03.IF") is None, timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: "q03.IF" not in _panel_child_paths(gui, "q01.IF"), + timeout=BROADCAST_TIMEOUT, + ) + assert "q03.IF" not in gui.locksPanel.rowWidgets + + # live update: a Lock the second Client makes appears as a new + # child row + second_pm.lock("other.x", "q02.IF") + qtbot.waitUntil( + lambda: "other.x" in _panel_child_paths(gui, "q02.IF"), + timeout=BROADCAST_TIMEOUT, + ) + + # the second Client unlocks q01.IF: the toggle goes unlocked and + # the value cell becomes an editor again + second_pm.unlock("q01.IF") + + def _q01_unlocked_in_panel(): + entry = gui.locksPanel.rowWidgets.get("q01.IF") + return ( + entry is not None + and entry["toggle"] is not None + and entry["toggle"].property("locked") is False + and entry["editor"] is not None + ) + + qtbot.waitUntil(_q01_unlocked_in_panel, timeout=BROADCAST_TIMEOUT) + finally: + gui.model.stopListener() + + +def test_the_type_lock_rows_lock_all_and_remove_rule( + qtbot, pm, second_client, server_port +): + """A Type Lock Target is the panel's first root, labelled with its + Type; "remove rule" clears only the rule and leaves the Locks; and + "lock all" locks an unlocked Follower again through the stored + Target.""" + second_pm = _second_parameter_manager(second_client) + second_pm.add_parameter("dq01.IF", initial_value=1.0, unit="Hz") + second_pm.add_parameter("dq02.IF", initial_value=2.0, unit="Hz") + # a root-level parameter: the root is never an Instance, so targeting + # it cannot self-lock an Instance parameter + second_pm.add_parameter("tshared", initial_value=0.0, unit="Hz") + second_pm.add_type("dqubit") + second_pm.add_type_parameter("dqubit", "IF", default=1.0, unit="Hz") + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # an explicit Target, so no Globals parameter is created and no + # parameter-creation Broadcast hits the model's creation branch + second_pm.lock_type_parameter("dqubit", "IF", target="tshared") + qtbot.waitUntil( + lambda: ( + gui.state.types.get("dqubit") is not None + and gui.state.types["dqubit"].parameters["IF"]["target"] + == f"{PM_NAME}.tshared" + ), + timeout=BROADCAST_TIMEOUT, + ) + + gui.locksAction.trigger() + qtbot.waitUntil( + lambda: _panel_root_paths(gui) == ["tshared"], + timeout=BROADCAST_TIMEOUT, + ) + assert _panel_row_items(gui, "tshared")[0].text() == "[type: dqubit] tshared" + assert _panel_child_paths(gui, "tshared") == ["dq01.IF", "dq02.IF"] + + # "remove rule": only the rule goes; both Locks stay + gui.locksPanel.rowWidgets["tshared"]["removeRule"].click() + qtbot.waitUntil( + lambda: pm.get_type("dqubit").parameters["IF"]["target"] is None, + timeout=BROADCAST_TIMEOUT, + ) + assert set(pm.list_locks()) == {"dq01.IF", "dq02.IF"} + qtbot.waitUntil( + lambda: _panel_row_items(gui, "tshared")[0].text() == "tshared", + timeout=BROADCAST_TIMEOUT, + ) + + # re-declare the Type Lock from the second Client, then unlock + # dq01.IF from there too; the panel's row carries its "lock all" + # button again once the Type Broadcast arrived and the rebuild ran + second_pm.lock_type_parameter("dqubit", "IF", target="tshared") + second_pm.unlock("dq01.IF") + qtbot.waitUntil( + lambda: ( + pm.get_lock("dq01.IF") is not None + and pm.get_lock("dq01.IF").locked is False + ), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("dq01.IF") is not None + and gui.state.locks["dq01.IF"].locked is False + ), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: ( + gui.locksPanel.rowWidgets.get("tshared") is not None + and gui.locksPanel.rowWidgets["tshared"]["lockAll"] is not None + ), + timeout=BROADCAST_TIMEOUT, + ) + + # ... and "lock all" locks it again through the stored Target + gui.locksPanel.rowWidgets["tshared"]["lockAll"].click() + qtbot.waitUntil( + lambda: ( + pm.get_lock("dq01.IF") is not None and pm.get_lock("dq01.IF").locked + ), + timeout=BROADCAST_TIMEOUT, + ) + # "lock all" passes the entry's stored Target: a call without it + # would re-point the rule to the Globals default (D17) + qtbot.waitUntil( + lambda: ( + pm.get_type("dqubit").parameters["IF"]["target"] == f"{PM_NAME}.tshared" + ), + timeout=BROADCAST_TIMEOUT, + ) + assert pm.get_lock("dq01.IF").target == f"{PM_NAME}.tshared" + assert not any(path.startswith("_globals") for path in pm.list()) + finally: + gui.model.stopListener() + + +def test_the_lock_all_note_names_the_skipped_followers( + qtbot, pm, second_client, server_port +): + """ "lock all" names the Instance parameters it skips on the note + label and leaves them locked to their own Target (D17): one whose + Lock the second Client re-targeted keeps that Target.""" + second_pm = _second_parameter_manager(second_client) + second_pm.add_parameter("dq01.IF", initial_value=1.0, unit="Hz") + second_pm.add_parameter("dq02.IF", initial_value=2.0, unit="Hz") + # root-level parameters: the root is never an Instance, so targeting + # them cannot self-lock an Instance parameter + second_pm.add_parameter("tshared", initial_value=0.0, unit="Hz") + second_pm.add_parameter("talt", initial_value=9.0, unit="Hz") + second_pm.add_type("dqubit") + second_pm.add_type_parameter("dqubit", "IF", default=1.0, unit="Hz") + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # an explicit Target, so no Globals parameter is created and no + # parameter-creation Broadcast hits the model's creation branch + second_pm.lock_type_parameter("dqubit", "IF", target="tshared") + qtbot.waitUntil( + lambda: ( + gui.state.types.get("dqubit") is not None + and gui.state.types["dqubit"].parameters["IF"]["target"] + == f"{PM_NAME}.tshared" + ), + timeout=BROADCAST_TIMEOUT, + ) + + gui.locksAction.trigger() + qtbot.waitUntil( + lambda: _panel_root_paths(gui) == ["tshared"], + timeout=BROADCAST_TIMEOUT, + ) + + # the second Client re-targets one Instance parameter; waiting for + # the GUI's state makes sure the rebuild that replaces the panel's + # buttons has run before the click + second_pm.lock("dq01.IF", "talt") + qtbot.waitUntil( + lambda: ( + pm.get_lock("dq01.IF") is not None + and pm.get_lock("dq01.IF").target == f"{PM_NAME}.talt" + ), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("dq01.IF") is not None + and gui.state.locks["dq01.IF"].target == f"{PM_NAME}.talt" + ), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: ( + gui.locksPanel.rowWidgets.get("tshared") is not None + and gui.locksPanel.rowWidgets["tshared"]["lockAll"] is not None + ), + timeout=BROADCAST_TIMEOUT, + ) + + # "lock all": dq01.IF is skipped, named on the note label, and + # stays locked to its own Target + gui.locksPanel.rowWidgets["tshared"]["lockAll"].click() + qtbot.waitUntil( + lambda: "skipped: dq01.IF" in gui.locksPanel.noteLabel.text(), + timeout=BROADCAST_TIMEOUT, + ) + assert pm.get_lock("dq01.IF").target == f"{PM_NAME}.talt" + finally: + gui.model.stopListener() + + +def test_the_panel_value_editor_sets_the_target(qtbot, pm, second_client, server_port): + """Typing a value into the root Target's editor and pressing its set + button sets the parameter on the Server, and the tree's Follower row + repaints to it (5.3's repaint path). A second Client's set repaints + the panel in place: the root editor and the locked Follower's + read-only label both show the new value without a rebuild.""" + # the second Client's proxy is built after the parameters exist, so + # its blueprint knows q02.IF (attribute access resolves through it) + _make_live_parameters(pm) + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.lock("q01.IF", "q02.IF") + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + ), + timeout=BROADCAST_TIMEOUT, + ) + + gui.locksAction.trigger() + qtbot.waitUntil( + lambda: _panel_root_paths(gui) == ["q02.IF"], + timeout=BROADCAST_TIMEOUT, + ) + editor = gui.locksPanel.rowWidgets["q02.IF"]["editor"] + assert editor is not None + + editor.paramWidget.input.setText("11") + editor.setButton.click() + qtbot.waitUntil(lambda: pm.q02.IF.get() == 11, timeout=BROADCAST_TIMEOUT) + tree_widget = gui.view.delegate.parameters["q01.IF"] + qtbot.waitUntil( + lambda: tree_widget._getMethod() == 11, timeout=BROADCAST_TIMEOUT + ) + + # the second Client's set reaches the panel without a local echo: + # the root editor and the locked Follower's read-only label are + # refreshed in place (a no-op refresh_values would fail here) + second_pm.q02.IF.set(21) + qtbot.waitUntil( + lambda: ( + gui.locksPanel.rowWidgets.get("q02.IF") is not None + and gui.locksPanel.rowWidgets["q02.IF"]["editor"] is not None + and gui.locksPanel.rowWidgets["q02.IF"]["editor"]._getMethod() == 21 + ), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: ( + gui.locksPanel.rowWidgets.get("q01.IF") is not None + and gui.locksPanel.rowWidgets["q01.IF"]["label"] is not None + and gui.locksPanel.rowWidgets["q01.IF"]["label"].text() == "21" + ), + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_lock_selection_to_arms_the_tree_row(qtbot, pm, second_client, server_port): + """ "Lock selection to…" shows the tree's current parameter in the + selected label and arms the pick for it; on a submodule row it says so + on the note label and arms nothing.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + gui.locksAction.trigger() + assert gui.locksPanel.selectedLabel.text() == "no parameter selected" + + # select the other.x row in the tree: the label follows it + source_index = gui.model.indexFromItem(_row_items(gui, "other.x")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + qtbot.waitUntil( + lambda: gui.locksPanel.selectedLabel.text() == "other.x", + timeout=BROADCAST_TIMEOUT, + ) + + gui.locksPanel.lockSelectionButton.click() + assert gui.locksController.armed_follower == "other.x" + assert not gui.armStrip.isHidden() + + # a fresh pick, then a submodule row: the label shows that no + # parameter is selected and pressing arms nothing + gui.locksController.cancel_arm() + source_index = gui.model.indexFromItem(_row_items(gui, "other")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + qtbot.waitUntil( + lambda: gui.locksPanel.selectedLabel.text() == "no parameter selected", + timeout=BROADCAST_TIMEOUT, + ) + gui.locksPanel.lockSelectionButton.click() + assert ( + "Select a parameter in the tree first." in gui.locksPanel.noteLabel.text() + ) + assert gui.locksController.armed_follower is None + assert gui.armStrip.isHidden() + + # a successful arm clears the stale error from the note + # (plan task 5.6) + source_index = gui.model.indexFromItem(_row_items(gui, "other.x")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + gui.locksPanel.lockSelectionButton.click() + assert gui.locksController.armed_follower == "other.x" + assert gui.locksPanel.noteLabel.text() == LOCK_PANEL_NOTE + finally: + gui.model.stopListener() + + +def test_a_panel_action_error_shows_on_the_note_label(qtbot, pm, server_port): + """A refused panel action shows the Server's error text on the note + label, and the next successful action restores the default note. The + panel's toggle button runs the same action as the signal: unlock, then + lock again.""" + pm.add_parameter("q01.x", initial_value=1.0, unit="Hz") + pm.add_parameter("q02.x", initial_value=2.0, unit="Hz") + pm.lock("q02.x", "q01.x") + pm.update() # the GUI's tree is built from the proxy's blueprint + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.locksAction.trigger() + + # the panel's toggle button is wired to the same action: unlock, + # then lock again; the rebuild replaces the button, so it is + # re-read from rowWidgets before the second click + gui.locksPanel.rowWidgets["q02.x"]["toggle"].click() + qtbot.waitUntil( + lambda: pm.get_lock("q02.x").locked is False, + timeout=BROADCAST_TIMEOUT, + ) + + def _unlocked_toggle_back(): + entry = gui.locksPanel.rowWidgets.get("q02.x") + return ( + entry is not None + and entry["toggle"] is not None + and entry["toggle"].property("locked") is False + ) + + qtbot.waitUntil(_unlocked_toggle_back, timeout=BROADCAST_TIMEOUT) + gui.locksPanel.rowWidgets["q02.x"]["toggle"].click() + qtbot.waitUntil( + lambda: pm.get_lock("q02.x").locked is True, + timeout=BROADCAST_TIMEOUT, + ) + + gui.locksPanel.toggleLockRequested.emit("no.such") + assert "no.such" in gui.locksPanel.noteLabel.text() + + # a successful action restores the default note + gui.locksPanel.toggleLockRequested.emit("q02.x") + qtbot.waitUntil( + lambda: pm.get_lock("q02.x").locked is False, + timeout=BROADCAST_TIMEOUT, + ) + assert gui.locksPanel.noteLabel.text() == LOCK_PANEL_NOTE + finally: + gui.model.stopListener() + + +def test_an_open_panel_asks_the_server_nothing_while_idle( + qtbot, pm, second_client, server_port +): + """The Server broadcasts a parameter-call for every ``get``, so a + handler that answers a value Broadcast with a ``get`` feeds itself: an + open panel with a locked Follower used to ask the Server thousands of + times a second. With the panel open and nothing happening, the GUI + must send no request at all, and a second Client's Target update must + still reach the Follower's label through the Broadcast's value.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + second_pm.lock("q01.IF", "q02.IF") + second_pm.lock("q03.IF", "q01.IF") + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + gui.locksAction.trigger() + qtbot.waitUntil( + lambda: "q03.IF" in gui.locksPanel.rowWidgets, + timeout=BROADCAST_TIMEOUT, + ) + qtbot.wait(300) # let the Broadcasts the panel's rebuild caused land + + asks = [] + original_ask = pm.cli.ask + + def counting_ask(message): + asks.append(message) + return original_ask(message) + + pm.cli.ask = counting_ask + try: + qtbot.wait(1000) + assert asks == [] + + second_pm.update() # it was made before the parameters existed + second_pm.q02.IF(7.5) + qtbot.waitUntil( + lambda: gui.locksPanel.rowWidgets["q03.IF"]["label"].text() == "7.5", + timeout=BROADCAST_TIMEOUT, + ) + assert gui.locksPanel.rowWidgets["q01.IF"]["label"].text() == "7.5" + assert asks == [] + finally: + del pm.cli.ask + finally: + gui.model.stopListener() + + +def test_filtering_away_a_locked_row_and_back_does_not_crash(qtbot, pm, server_port): + """Qt deletes a row's editor when the filter hides the row, and every + filter change re-applies the Locks to the rows' editors. The delegate + used to keep the deleted editor, so typing a filter that hid a locked + row ("L", then "LO") raised "wrapped C/C++ object of type QPushButton + has been deleted". Hiding the row and bringing it back must raise + nothing, and the returning row's editor must carry its Lock again.""" + _make_live_parameters(pm) + pm.lock("q01.IF", "q02.IF") + pm.update() + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.show() + qtbot.waitExposed(gui) + gui.view.expandAll() + with qtbot.captureExceptions() as exceptions: + for text in ["L", "LO", "L", ""]: + gui.lineEdit.setText(text) + qtbot.wait(50) + assert exceptions == [] + + widget = gui.view.delegate.parameters["q01.IF"] + assert widget.lockButton.isVisibleTo(widget) + assert widget.read_only is True + finally: + gui.model.stopListener() + + +# --------------------------------------------------------------------------- +# plan task 5.5: the Types tab (and the parameter-creation branch fix) +# --------------------------------------------------------------------------- + + +def _row_exists(gui, path): + """Whether the parameters tree holds the row ``path`` (a soft check + for ``qtbot.waitUntil`` callbacks).""" + matches = gui.model.findItems( + path, + QtCore.Qt.MatchFlag.MatchExactly | QtCore.Qt.MatchFlag.MatchRecursive, + 0, + ) + return bool(matches) + + +def _state_type_has(gui, type_name, path): + """Whether the GUI's state holds the Type ``type_name`` with the + entry ``path``.""" + blueprint = gui.state.types.get(type_name) + return blueprint is not None and path in blueprint.parameters + + +def _type_list_row(gui, name): + """The three items of the Types pane's type-list row ``name``, or + ``None`` while the row does not exist.""" + model = gui.typesPane.typeModel + matches = model.findItems(name, QtCore.Qt.MatchFlag.MatchExactly, 0) + if not matches: + return None + row = matches[0].row() + return [model.item(row, column) for column in range(3)] + + +def _entry_row_items(gui, path): + """The four items of the entries-pane row ``path`` (walked segment by + segment through the tree), or ``None`` while the row does not + exist.""" + item = gui.typesPane.entriesModel.invisibleRootItem() + for segment in path.split("."): + found = None + for row in range(item.rowCount()): + child = item.child(row, 0) + if child is not None and child.text() == segment: + found = child + break + if found is None: + return None + item = found + parent = item.parent() + if parent is None: + model = gui.typesPane.entriesModel + return [model.item(item.row(), column) for column in range(4)] + return [parent.child(item.row(), column) for column in range(4)] + + +def _instance_row_items(gui, name): + """The four items of the instances-pane row ``name``, or ``None`` + while the row does not exist.""" + model = gui.typesPane.instancesModel + matches = model.findItems(name, QtCore.Qt.MatchFlag.MatchExactly, 0) + if not matches: + return None + row = matches[0].row() + return [model.item(row, column) for column in range(4)] + + +def _create_type_with_instance(qtbot, gui, pm, type_name="qubit", instance="q10"): + """Create the Type ``type_name`` with one entry ``IF`` (default 1.0, + unit Hz) and the Instance ``instance`` through the Types pane's + widgets, waiting for the Server state and the pane rows each step.""" + gui.tabs.setCurrentIndex(1) + gui.typesPane.newTypeEdit.setText(type_name) + gui.typesPane.addTypeButton.click() + qtbot.waitUntil(lambda: type_name in pm.list_types(), timeout=BROADCAST_TIMEOUT) + qtbot.waitUntil( + lambda: ( + gui.typesPane.selectedType == type_name + and _type_list_row(gui, type_name) is not None + ), + timeout=BROADCAST_TIMEOUT, + ) + gui.typesPane.entryNameEdit.setText("IF") + gui.typesPane.entryDefaultEdit.setText("1.0") + gui.typesPane.entryUnitEdit.setText("Hz") + gui.typesPane.addEntryButton.click() + qtbot.waitUntil( + lambda: _state_type_has(gui, type_name, "IF"), timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: gui.typesPane.entryWidgets.get("IF", {}).get("editor") is not None, + timeout=BROADCAST_TIMEOUT, + ) + gui.typesPane.newInstanceEdit.setText(instance) + gui.typesPane.addInstanceButton.click() + qtbot.waitUntil(lambda: pm.has_param(f"{instance}.IF"), timeout=BROADCAST_TIMEOUT) + qtbot.waitUntil( + lambda: _instance_row_items(gui, instance) is not None, + timeout=BROADCAST_TIMEOUT, + ) + + +def test_type_entry_rows_build_the_segment_sorted_tree(): + """The entries rows of a Type with a Nested Type: segment-wise order + (the mock's sort), submodule rows naming the Nested Type they + require, own entries marked with their default and Target, nested + entries carrying the defining Type and its own default.""" + readout = _type_blueprint("readout", {"bw": "Hz"}, defaults={"bw": 2.0}) + qubit = _type_blueprint( + "qubit", + {"IF": "Hz"}, + nested={"readout": "readout"}, + registry={"readout": readout}, + defaults={"IF": 1.0}, + targets={"IF": f"{PM_NAME}._globals.qubit.IF"}, + ) + rows = type_entry_rows("qubit", {"readout": readout, "qubit": qubit}, PM_NAME) + assert [row.path for row in rows] == ["IF", "readout", "readout.bw"] + assert rows[0].kind == "entry" and rows[0].own + assert rows[0].from_type == "qubit" + assert rows[0].unit == "Hz" and rows[0].default == 1.0 + assert rows[0].target == "_globals.qubit.IF" + assert rows[1].kind == "submodule" and rows[1].nested_type == "readout" + assert rows[2].kind == "entry" and not rows[2].own + assert rows[2].from_type == "readout" + assert rows[2].unit == "Hz" and rows[2].default == 2.0 + assert rows[2].target is None + + +def test_type_entry_rows_walks_deeper_nested_submodules(): + """A Nested Type's own Nested Type: the deeper submodule row names the + Type required there (the mock's ``at`` walk), and its entries carry + the defining Type's own default for the path relative to it; a + submodule with no Nested Type is a structural row.""" + pulse_window = _type_blueprint("pulse_window", {"win": "s"}, defaults={"win": 5}) + readout = _type_blueprint( + "readout", + {"bw": "Hz"}, + nested={"pw": "pulse_window"}, + registry={"pulse_window": pulse_window}, + ) + qubit = _type_blueprint( + "qubit", + {"IF": "Hz"}, + nested={"readout": "readout"}, + registry={"readout": readout}, + ) + types = {"pulse_window": pulse_window, "readout": readout, "qubit": qubit} + rows = type_entry_rows("qubit", types) + assert [row.path for row in rows] == [ + "IF", + "readout", + "readout.bw", + "readout.pw", + "readout.pw.win", + ] + by_path = {row.path: row for row in rows} + assert by_path["readout"].nested_type == "readout" + assert by_path["readout.pw"].nested_type == "pulse_window" + assert by_path["readout.pw.win"].kind == "entry" + assert by_path["readout.pw.win"].from_type == "pulse_window" + assert by_path["readout.pw.win"].default == 5 + + deep = _type_blueprint("dq", {"short.win": "s"}) + rows = type_entry_rows("dq", {"dq": deep}) + assert [row.path for row in rows] == ["short", "short.win"] + assert rows[0].nested_type is None + + +def test_type_entry_rows_of_an_unknown_type(): + """An unknown Type yields no rows.""" + assert type_entry_rows("gone", {}) == [] + + +def test_instances_of_type_matches_like_compute_claims(): + """The client-side Instance matching follows the rules + ``compute_claims`` matches by: every effective path with the declared + unit, the root never, Globals never, an empty Type never, extra + parameters do not matter.""" + qubit = _type_blueprint("qubit", {"IF": "Hz", "bw": "Hz"}) + types = {"qubit": qubit, "empty_type": _type_blueprint("empty_type", {})} + parameters = { + "q10.IF": "Hz", + "q10.bw": "Hz", + "q10.gain": "dB", + "IF": "Hz", + "bw": "Hz", + "_globals.qubit.IF": "Hz", + "_globals.qubit.bw": "Hz", + "wrong.IF": "Hz", + "wrong.bw": "V", + } + assert instances_of_type("qubit", types, parameters) == ["q10"] + # one path missing or one unit off: no Instance + assert instances_of_type("qubit", types, {"q10.IF": "Hz"}) == [] + assert instances_of_type("qubit", types, {"q10.IF": "Hz", "q10.bw": "V"}) == [] + # a genuinely empty Type in the registry has no Instances (D12) + assert instances_of_type("empty_type", types, parameters) == [] + assert instances_of_type("empty", types, parameters) == [] + + +def test_instances_of_type_with_a_nested_type(): + """The client-side matching expands Nested Types the way the Server's + ``instances_of`` does (D12): the submodule must carry the outer + Type's entries and the Nested Type's entries under their submodule, + each with the unit the declaring Type declares.""" + readout = _type_blueprint("readout", {"bw": "Hz"}) + qubit = _type_blueprint( + "qubit", + {"IF": "Hz"}, + nested={"readout": "readout"}, + registry={"readout": readout}, + ) + types = {"qubit": qubit} + parameters = {"q10.IF": "Hz", "q10.readout.bw": "Hz"} + assert instances_of_type("qubit", types, parameters) == ["q10"] + # the nested entry's unit is off: no Instance + assert ( + instances_of_type("qubit", types, {"q10.IF": "Hz", "q10.readout.bw": "V"}) == [] + ) + # the nested entry is missing: no Instance + assert instances_of_type("qubit", types, {"q10.IF": "Hz"}) == [] + + +def test_also_types_lists_every_type_the_submodule_carries(): + """A submodule can be an Instance of several Types; ``also_types`` + lists them all in registry order (the Types pane filters the selected + one out when it composes the ``also`` text).""" + big = _type_blueprint("big", {"a": "Hz", "b": "Hz"}) + small = _type_blueprint("small", {"a": "Hz"}) + types = {"big": big, "small": small} + parameters = {"q01.a": "Hz", "q01.b": "Hz"} + assert also_types("q01", types, parameters) == ["big", "small"] + assert also_types("other", types, parameters) == [] + + +def test_parse_default_text(): + """An empty default text parses to ``None``, a literal to its value, + and anything else stays the raw string.""" + assert parse_default_text("") is None + assert parse_default_text(" ") is None + assert parse_default_text("1.0") == 1.0 + assert parse_default_text("3") == 3 + assert parse_default_text("True") is True + assert parse_default_text("abc") == "abc" + assert parse_default_text("'xy'") == "xy" + + +def test_rank_lock_targets_ranks_for_a_type_lock_arm(): + """With an explicit ``arm_rel`` — the Types tab's Type Lock re-target + — the entry path ranks the candidates: the same leaf on any Instance + first, then submodules on the way, everything else last. There is no + Follower to exclude.""" + claims = { + "q01.IF": Claim(type="qubit", instance="q01", stack=["qubit"]), + "q02.IF": Claim(type="qubit", instance="q02", stack=["qubit"]), + } + ranked = rank_lock_targets( + "", ["other.x", "q02.IF", "q05.IF.gain", "q01.IF"], claims, arm_rel="IF" + ) + assert ranked == ["q01.IF", "q02.IF", "q05.IF.gain", "other.x"] + + +def test_a_creation_from_a_second_client_under_an_existing_submodule_appears( + qtbot, pm, second_client, server_port +): + """Regression (plan task 5.5, reading 0): a parameter another Client + creates under an existing submodule while the GUI is open appears in + the model with its delegate widget.""" + second_pm = _second_parameter_manager(second_client) + pm.add_parameter("cr01.x", initial_value=1.0, unit="Hz") + pm.update() # the GUI's tree is built from the proxy's blueprint + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.add_parameter("cr01.y", initial_value=2.0, unit="Hz") + qtbot.waitUntil( + lambda: ( + _row_exists(gui, "cr01.y") and "cr01.y" in gui.view.delegate.parameters + ), + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_a_creation_from_a_second_client_in_a_new_submodule_appears( + qtbot, pm, second_client, server_port +): + """Regression: the same for a parameter that brings a new submodule + with it.""" + second_pm = _second_parameter_manager(second_client) + pm.update() + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.add_parameter("crnew.z", initial_value=3.0, unit="s") + qtbot.waitUntil( + lambda: ( + _row_exists(gui, "crnew") + and _row_exists(gui, "crnew.z") + and "crnew.z" in gui.view.delegate.parameters + ), + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_an_add_instance_from_a_second_client_appears_and_tints( + qtbot, pm, second_client, server_port +): + """Regression: an Instance a second Client creates while the GUI is + open appears row by row, with widgets, and the tints recompute so the + new Instance's rows carry the Type's tint.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.add_type("insttype") + second_pm.add_type_parameter("insttype", "ix", default=1.0, unit="Hz") + second_pm.add_type_parameter("insttype", "iy", default=2.0, unit="Hz") + qtbot.waitUntil( + lambda: _state_type_has(gui, "insttype", "iy"), + timeout=BROADCAST_TIMEOUT, + ) + + second_pm.add_instance("insttype", "instq") + qtbot.waitUntil( + lambda: ( + _row_exists(gui, "instq.ix") + and _row_exists(gui, "instq.iy") + and "instq.ix" in gui.view.delegate.parameters + and "instq.iy" in gui.view.delegate.parameters + ), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: ( + _type_tint(gui, "insttype") is not None + and _row_items(gui, "instq.ix")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "insttype") + and _row_items(gui, "instq.iy")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "insttype") + ), + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_an_add_instance_from_the_gui_proxy_appears( + qtbot, pm, second_client, server_port +): + """Regression: the same for the GUI's own Proxy calling + ``add_instance`` — and the Proxy is refreshed by the creation branch, + so attribute access resolves the created parameters.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + second_pm.add_type("owntype") + second_pm.add_type_parameter("owntype", "ox", default=1.0, unit="Hz") + second_pm.add_type_parameter("owntype", "oy", default=2.0, unit="Hz") + qtbot.waitUntil( + lambda: _state_type_has(gui, "owntype", "oy"), + timeout=BROADCAST_TIMEOUT, + ) + + pm.add_instance("owntype", "ownq") + qtbot.waitUntil( + lambda: ( + _row_exists(gui, "ownq.ox") + and _row_exists(gui, "ownq.oy") + and "ownq.ox" in gui.view.delegate.parameters + ), + timeout=BROADCAST_TIMEOUT, + ) + assert pm.ownq.ox.get() == 1.0 + assert pm.ownq.oy.get() == 2.0 + qtbot.waitUntil( + lambda: ( + _type_tint(gui, "owntype") is not None + and _row_items(gui, "ownq.ox")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "owntype") + ), + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_the_types_tab_creates_a_type_and_an_instance( + qtbot, pm, second_client, server_port +): + """The plan's named flow: a Type and an Instance created through the + Types pane's widgets, with the Server state matching and the type + list's Instances and parameter counts following; the new Instance's + tree row carries the Type's tint; a second Client's entry appears in + the panes and the tree without any GUI action.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + # no Type selected yet: the labels carry no trailing space and the + # three strips are disabled (plan task 5.6) + assert gui.typesPane.entriesLabel.text() == "parameters" + assert gui.typesPane.instancesLabel.text() == "instances" + assert not gui.typesPane.addEntryButton.isEnabled() + assert not gui.typesPane.addNestedButton.isEnabled() + assert not gui.typesPane.addInstanceButton.isEnabled() + + # create the Type through the widgets + gui.tabs.setCurrentIndex(1) + gui.typesPane.newTypeEdit.setText("qubit") + gui.typesPane.addTypeButton.click() + qtbot.waitUntil(lambda: "qubit" in pm.list_types(), timeout=BROADCAST_TIMEOUT) + qtbot.waitUntil( + lambda: ( + gui.typesPane.selectedType == "qubit" + and _type_list_row(gui, "qubit") is not None + ), + timeout=BROADCAST_TIMEOUT, + ) + # with the Type selected the strips are enabled and the labels + # name it + assert gui.typesPane.addEntryButton.isEnabled() + assert gui.typesPane.addNestedButton.isEnabled() + assert gui.typesPane.addInstanceButton.isEnabled() + assert gui.typesPane.entriesLabel.text() == "parameters of qubit" + assert gui.typesPane.instancesLabel.text() == "instances of qubit" + + # add the entry through the widgets + gui.typesPane.entryNameEdit.setText("IF") + gui.typesPane.entryDefaultEdit.setText("1.0") + gui.typesPane.entryUnitEdit.setText("Hz") + gui.typesPane.addEntryButton.click() + qtbot.waitUntil( + lambda: _state_type_has(gui, "qubit", "IF"), timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: gui.typesPane.entryWidgets.get("IF", {}).get("editor") is not None, + timeout=BROADCAST_TIMEOUT, + ) + # the type list counts: one effective parameter, no Instances yet + assert _type_list_row(gui, "qubit")[2].text() == "1" + assert _type_list_row(gui, "qubit")[1].text() == "0" + + # the Server holds the entry with its default and unit + assert pm.get_type("qubit").parameters["IF"] == { + "default": 1.0, + "unit": "Hz", + "target": None, + } + # the type list shows the row, selected, with its counts + row = _type_list_row(gui, "qubit") + assert row is not None + current = gui.typesPane.typeModel.item( + gui.typesPane.typeList.currentIndex().row(), 0 + ) + assert current is not None and current.text() == "qubit" + assert gui.typesPane.selectedType == "qubit" + # the entry row is in the pane + assert _entry_row_items(gui, "IF") is not None + + # create the Instance through the widgets + gui.typesPane.newInstanceEdit.setText("q10") + gui.typesPane.addInstanceButton.click() + qtbot.waitUntil(lambda: pm.has_param("q10.IF"), timeout=BROADCAST_TIMEOUT) + qtbot.waitUntil( + lambda: _instance_row_items(gui, "q10") is not None, + timeout=BROADCAST_TIMEOUT, + ) + # the Instances count follows the created Instance + qtbot.waitUntil( + lambda: _type_list_row(gui, "qubit")[1].text() == "1", + timeout=BROADCAST_TIMEOUT, + ) + # the Instance was created with the entry's default and unit + qtbot.waitUntil(lambda: _row_exists(gui, "q10.IF"), timeout=BROADCAST_TIMEOUT) + assert pm.q10.IF.get() == 1.0 + assert pm.q10.IF.unit == "Hz" + # the instances row shows the parameter count + assert _instance_row_items(gui, "q10")[1].text() == "1 parameters" + # the Parameters tree shows the q10.IF row tinted with qubit's colour + qtbot.waitUntil( + lambda: ( + _type_tint(gui, "qubit") is not None + and _row_items(gui, "q10.IF")[0].data( + QtCore.Qt.ItemDataRole.BackgroundRole + ) + in _type_tint(gui, "qubit") + ), + timeout=BROADCAST_TIMEOUT, + ) + + # a second Client's entry appears without any GUI action, and the + # effective parameter count follows it + second_pm.add_type_parameter("qubit", "bw", default=2.0, unit="Hz") + qtbot.waitUntil( + lambda: ( + _entry_row_items(gui, "bw") is not None + and _row_exists(gui, "q10.bw") + and _type_list_row(gui, "qubit")[2].text() == "2" + ), + timeout=BROADCAST_TIMEOUT, + ) + finally: + gui.model.stopListener() + + +def test_the_types_tab_edits_entries_and_nested_types( + qtbot, pm, second_client, server_port +): + """Entry edits through the widgets: the default editor sets the + entry's default on the Server, Remove takes the entry off the Type + while the Instance keeps the parameter (D13), and a Nested Type added + through the strips shows its submodule row and "defined by" entries + and creates the parameters on the Instances, until it is removed.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + _create_type_with_instance(qtbot, gui, pm) + + # change IF's default to 3.0 via the editor + set + entry = gui.typesPane.entryWidgets["IF"] + entry["editor"].setText("3.0") + entry["set"].click() + qtbot.waitUntil( + lambda: pm.get_type("qubit").parameters["IF"]["default"] == 3.0, + timeout=BROADCAST_TIMEOUT, + ) + + # a second entry via the strip, then Remove via the row button; + # the unit is typed with a trailing space, which the strip strips + # (D12 compares units exactly, so "Hz " would match no Instance) + gui.typesPane.entryNameEdit.setText("bw") + gui.typesPane.entryDefaultEdit.setText("2.0") + gui.typesPane.entryUnitEdit.setText("Hz ") + gui.typesPane.addEntryButton.click() + qtbot.waitUntil( + lambda: gui.typesPane.entryWidgets.get("bw", {}).get("remove") is not None, + timeout=BROADCAST_TIMEOUT, + ) + assert pm.get_type("qubit").parameters["bw"]["default"] == 2.0 + assert pm.get_type("qubit").parameters["bw"]["unit"] == "Hz" + gui.typesPane.entryWidgets["bw"]["remove"].click() + qtbot.waitUntil( + lambda: "bw" not in pm.get_type("qubit").parameters, + timeout=BROADCAST_TIMEOUT, + ) + # D13: the Instance keeps the parameter + assert pm.has_param("q10.bw") + + # nested: add Type "readout" with entry "bw" (Hz) through the + # widgets + gui.typesPane.newTypeEdit.setText("readout") + gui.typesPane.addTypeButton.click() + qtbot.waitUntil( + lambda: gui.typesPane.selectedType == "readout", + timeout=BROADCAST_TIMEOUT, + ) + gui.typesPane.entryNameEdit.setText("bw") + gui.typesPane.entryUnitEdit.setText("Hz") + gui.typesPane.addEntryButton.click() + qtbot.waitUntil( + lambda: "bw" in pm.get_type("readout").parameters, + timeout=BROADCAST_TIMEOUT, + ) + + # select "qubit" again through the type list, then nest readout + # at "readout" + row = _type_list_row(gui, "qubit") + assert row is not None + gui.typesPane.typeList.setCurrentIndex( + gui.typesPane.typeModel.indexFromItem(row[0]) + ) + qtbot.waitUntil( + lambda: gui.typesPane.selectedType == "qubit", + timeout=BROADCAST_TIMEOUT, + ) + gui.typesPane.nestedTypeCombo.setCurrentText("readout") + gui.typesPane.nestedAtEdit.setText("readout") + gui.typesPane.addNestedButton.click() + qtbot.waitUntil( + lambda: pm.get_type("qubit").nested == {"readout": "readout"}, + timeout=BROADCAST_TIMEOUT, + ) + # the entries pane shows the submodule row with its Nested Type + # and the defined-by entry + qtbot.waitUntil( + lambda: ( + _entry_row_items(gui, "readout") is not None + and _entry_row_items(gui, "readout.bw") is not None + ), + timeout=BROADCAST_TIMEOUT, + ) + assert _entry_row_items(gui, "readout")[2].text() == "type: readout" + defined_by = gui.typesPane.entryWidgets["readout.bw"]["definedBy"] + assert defined_by.text() == "defined by readout" + assert defined_by.toolTip() == "defined by readout — change the default there" + # the nested entry was created on the Instance + qtbot.waitUntil( + lambda: pm.has_param("q10.readout.bw"), timeout=BROADCAST_TIMEOUT + ) + + # Remove nested: only the requirement goes (D13) + gui.typesPane.entryWidgets["readout"]["removeNested"].click() + qtbot.waitUntil( + lambda: pm.get_type("qubit").nested == {}, timeout=BROADCAST_TIMEOUT + ) + assert pm.has_param("q10.readout.bw") + finally: + gui.model.stopListener() + + +def test_the_types_tab_type_locks_toggle_and_retarget( + qtbot, pm, second_client, server_port +): + """The Type Lock toggle puts the Globals default Target on the entry, + locks the Instance parameter and shows the ``_globals`` row in the + tree; toggling again removes only the rule (D17) and the Locks stay. + The re-target button arms the picker on the Parameters tab and the + picked row becomes the entry's Target.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + _create_type_with_instance(qtbot, gui, pm) + + globals_target = f"{PM_NAME}._globals.qubit.IF" + + def _state_target(): + blueprint = gui.state.types.get("qubit") + return None if blueprint is None else blueprint.parameters["IF"]["target"] + + # toggle on: the Globals default Target is created and locked + gui.typesPane.entryWidgets["IF"]["toggle"].click() + qtbot.waitUntil( + lambda: ( + _state_target() == globals_target + and pm.get_type("qubit").parameters["IF"]["target"] == globals_target + ), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: ( + pm.get_lock("q10.IF") + == PMLockBluePrint(target=globals_target, locked=True) + ), + timeout=BROADCAST_TIMEOUT, + ) + # the _globals.qubit.IF row appears in the tree + qtbot.waitUntil( + lambda: _row_exists(gui, "_globals.qubit.IF"), + timeout=BROADCAST_TIMEOUT, + ) + + # toggle again: the rule goes, the Lock stays (D17); the wait runs + # on the state, so the next toggle reads the current rule and not + # the one still in flight + gui.typesPane.entryWidgets["IF"]["toggle"].click() + qtbot.waitUntil( + lambda: ( + _state_target() is None + and pm.get_type("qubit").parameters["IF"]["target"] is None + ), + timeout=BROADCAST_TIMEOUT, + ) + assert pm.get_lock("q10.IF") is not None + + # toggle on once more: the re-target button needs a locked entry + gui.typesPane.entryWidgets["IF"]["toggle"].click() + qtbot.waitUntil( + lambda: ( + _state_target() == globals_target + and pm.get_type("qubit").parameters["IF"]["target"] == globals_target + ), + timeout=BROADCAST_TIMEOUT, + ) + + # re-target through the arm strip, with a root parameter created + # first + pm.add_parameter("tshared", initial_value=0.0, unit="Hz") + qtbot.waitUntil(lambda: _row_exists(gui, "tshared"), timeout=BROADCAST_TIMEOUT) + qtbot.waitUntil( + lambda: ( + gui.typesPane.entryWidgets.get("IF", {}).get("retarget") is not None + ), + timeout=BROADCAST_TIMEOUT, + ) + gui.typesPane.entryWidgets["IF"]["retarget"].click() + assert gui.tabs.currentIndex() == 0 + assert gui.armStrip.label.text() == "Target for type qubit · IF" + assert gui.locksController.armed_type_lock == ("qubit", "IF") + assert gui.locksController.armed_follower is None + + # a Target the Server refuses: the error text on the strip, which + # stays armed, and the entry's Target unchanged on the Server + gui.locksController.pick_lock_target("no.such.path") + qtbot.waitUntil( + lambda: "no.such.path" in gui.armStrip.errorLabel.text(), + timeout=BROADCAST_TIMEOUT, + ) + assert not gui.armStrip.errorLabel.isHidden() + assert not gui.armStrip.isHidden() + assert gui.locksController.armed_type_lock == ("qubit", "IF") + assert gui.locksController.armed_follower is None + assert pm.get_type("qubit").parameters["IF"]["target"] == globals_target + + gui.locksController.pick_lock_target("tshared") + qtbot.waitUntil( + lambda: ( + pm.get_type("qubit").parameters["IF"]["target"] == f"{PM_NAME}.tshared" + ), + timeout=BROADCAST_TIMEOUT, + ) + assert gui.armStrip.isHidden() + assert gui.locksController.armed_type_lock is None + assert gui.locksController.armed_follower is None + finally: + gui.model.stopListener() + + +def test_the_types_tab_names_skipped_locks_on_the_note( + qtbot, pm, second_client, server_port +): + """A Type Lock declaration skips the Instance parameters already + locked to another Target (D17) and names them on the entries pane's + note; the skipped parameter keeps its own Target.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + _create_type_with_instance(qtbot, gui, pm) + + pm.add_parameter("tshared", initial_value=0.0, unit="Hz") + qtbot.waitUntil(lambda: _row_exists(gui, "tshared"), timeout=BROADCAST_TIMEOUT) + pm.lock("q10.IF", "tshared") + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("q10.IF") + == PMLockBluePrint(target=f"{PM_NAME}.tshared", locked=True) + ), + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: gui.typesPane.entryWidgets.get("IF", {}).get("toggle") is not None, + timeout=BROADCAST_TIMEOUT, + ) + + gui.typesPane.entryWidgets["IF"]["toggle"].click() + qtbot.waitUntil( + lambda: "skipped: q10.IF" in gui.typesPane.entriesNote.text(), + timeout=BROADCAST_TIMEOUT, + ) + assert pm.get_lock("q10.IF").target == f"{PM_NAME}.tshared" + + # a clean Type Lock re-target with nothing skipped resets the + # note (plan task 5.6); re-targeting to the Follower's own Target + # skips nothing + gui.locksController.arm_type_lock("qubit", "IF") + assert gui.locksController.armed_type_lock == ("qubit", "IF") + gui.locksController.pick_lock_target("tshared") + qtbot.waitUntil( + lambda: ( + pm.get_type("qubit").parameters["IF"]["target"] == f"{PM_NAME}.tshared" + ), + timeout=BROADCAST_TIMEOUT, + ) + assert gui.typesPane.entriesNote.text() == "" + assert gui.locksController.armed_type_lock is None + finally: + gui.model.stopListener() + + +def test_the_types_tab_show_button_and_also_types( + qtbot, pm, second_client, server_port +): + """An instance row's Show button switches to the Parameters tab, + clears the filter and selects the Instance's first parameter row; a + second Type the Instance also carries shows as ``also ``.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + _create_type_with_instance(qtbot, gui, pm) + qtbot.waitUntil( + lambda: gui.typesPane.showButtons.get("q10") is not None, + timeout=BROADCAST_TIMEOUT, + ) + + # a second Type whose set q10 also carries + second_pm.add_type("smallq") + second_pm.add_type_parameter("smallq", "IF", unit="Hz") + qtbot.waitUntil( + lambda: ( + _instance_row_items(gui, "q10") is not None + and _instance_row_items(gui, "q10")[2].text() == "also smallq" + ), + timeout=BROADCAST_TIMEOUT, + ) + + # press q10's Show: tab 0, filter empty, the current row is q10.IF + gui.lineEdit.setText("no-such-parameter") + gui.typesPane.showButtons["q10"].click() + assert gui.tabs.currentIndex() == 0 + qtbot.waitUntil(lambda: gui.lineEdit.text() == "") + current = gui._getCurrentItem() + assert current is not None and current.name == "q10.IF" + finally: + gui.model.stopListener() + + +def test_the_types_tab_shows_server_errors_and_empty_names( + qtbot, pm, second_client, server_port +): + """The strips show the Server's refusal for a repeated name on their + note and their own note for an empty name.""" + second_pm = _second_parameter_manager(second_client) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + + gui.tabs.setCurrentIndex(1) + gui.typesPane.newTypeEdit.setText("errtype") + gui.typesPane.addTypeButton.click() + qtbot.waitUntil(lambda: "errtype" in pm.list_types(), timeout=BROADCAST_TIMEOUT) + qtbot.waitUntil( + lambda: gui.typesPane.selectedType == "errtype", + timeout=BROADCAST_TIMEOUT, + ) + + # an existing name: the Server's text on the note + gui.typesPane.newTypeEdit.setText("errtype") + gui.typesPane.addTypeButton.click() + qtbot.waitUntil( + lambda: "already exists" in gui.typesPane.typeNote.text(), + timeout=BROADCAST_TIMEOUT, + ) + + # an empty name: the strip's own note + gui.typesPane.newTypeEdit.setText("") + gui.typesPane.addTypeButton.click() + assert gui.typesPane.typeNote.text() == "Name must not be empty." + + # the New instance and "Add to type" strips refuse empty names too + gui.typesPane.newInstanceEdit.setText("") + gui.typesPane.addInstanceButton.click() + assert gui.typesPane.instancesNote.text() == "Name must not be empty." + gui.typesPane.entryNameEdit.setText("") + gui.typesPane.addEntryButton.click() + assert gui.typesPane.entriesNote.text() == "Name must not be empty." + + # the "Nested type" strip refuses an empty submodule before any + # Server call: a second Type is created so the combo has the + # other Type to offer + gui.typesPane.newTypeEdit.setText("errtype2") + gui.typesPane.addTypeButton.click() + qtbot.waitUntil( + lambda: gui.typesPane.selectedType == "errtype2", + timeout=BROADCAST_TIMEOUT, + ) + qtbot.waitUntil( + lambda: gui.typesPane.nestedTypeCombo.currentText() == "errtype", + timeout=BROADCAST_TIMEOUT, + ) + gui.typesPane.nestedAtEdit.setText("") + gui.typesPane.addNestedButton.click() + assert gui.typesPane.entriesNote.text() == "Submodule must not be empty." + # the Server's Nested Types are untouched + assert pm.get_type("errtype2").nested == {} + finally: + gui.model.stopListener() + + +# --------------------------------------------------------------------------- +# plan task 5.6: delete-Target confirmation, shortcuts, icons, polish +# --------------------------------------------------------------------------- + + +def test_lock_and_unlock_icons_ship_in_the_resources(): + """resource.qrc lists lock.svg and unlock.svg (plan task 5.3 copied + them into resource/icons), so the compiled resources expose both.""" + import instrumentserver.resource # noqa: F401 + + assert QtCore.QFile.exists(":/icons/lock.svg") + assert QtCore.QFile.exists(":/icons/unlock.svg") + + +def test_the_lock_shortcuts_are_in_the_registry(): + """The three shortcut REGISTRY entries (plan task 5.6) sit after + ``toggle_locks`` — the first key stays ``jump_filter`` — and no key + collides with another entry.""" + registry = KeyboardShortcutManager.REGISTRY + assert registry["lock_to"] == ( + "Ctrl+L", + "Lock the selected parameter to… (pick a Target)", + ) + assert registry["unlock_item"] == ("Ctrl+U", "Unlock the selected parameter") + assert registry["show_types"] == ( + "Ctrl+Shift+Y", + "Switch between the Parameters and Types tabs", + ) + assert list(registry)[0] == "jump_filter" + keys = [entry[0] for entry in registry.values()] + assert len(keys) == len(set(keys)) + + +def _row_remove_button(gui, path): + """The row's delete button (the delegate's additional widget with the + ``Delete this parameter`` tooltip).""" + widget = gui.view.delegate.parameters[path] + buttons = [ + button + for button in widget.findChildren(QtWidgets.QPushButton) + if button.toolTip() == "Delete this parameter" + ] + assert len(buttons) == 1, f"expected one delete button on {path}" + return buttons[0] + + +def test_removing_a_target_confirms_and_cancel_keeps_the_server_untouched( + qtbot, pm, second_client, server_port +): + """The plan's named test: deleting a Target — through the row's delete + button or the delete_item shortcut — pops a QMessageBox naming the + Followers that will lose their Locks (locked and unlocked alike, with + Cancel as the default button); Cancel leaves the Server untouched and + no pm-lock-update is emitted, Ok removes the Target and drops the + Locks, and a parameter without Followers is removed without a + dialog.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + second_pm.lock("q01.IF", "q02.IF") + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + ), + timeout=BROADCAST_TIMEOUT, + ) + # a second Follower whose Lock the second Client unlocked: the + # dialog names both states + second_pm.lock("q03.IF", "q02.IF") + second_pm.unlock("q03.IF") + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("q03.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=False) + ), + timeout=BROADCAST_TIMEOUT, + ) + assert gui.removalDialog is None + + def _cancel_dialog(): + dialog = gui.removalDialog + assert dialog is not None + # macOS ignores a QMessageBox's window title (it reads back + # empty there), so the object name and text pin the dialog + assert dialog.objectName() == "removalDialog" + assert dialog.text().startswith( + "Removing q02.IF also removes the Locks of:" + ) + assert "q01.IF (locked)" in dialog.text() + assert "q03.IF (unlocked)" in dialog.text() + assert dialog.standardButtons() & QtWidgets.QMessageBox.StandardButton.Ok + assert ( + dialog.standardButtons() & QtWidgets.QMessageBox.StandardButton.Cancel + ) + assert dialog.defaultButton() is dialog.button( + QtWidgets.QMessageBox.StandardButton.Cancel + ) + dialog.button(QtWidgets.QMessageBox.StandardButton.Cancel).click() + + # the row's delete button path: the dialog appears, Cancel keeps + # the Target and its Followers' Locks untouched + QtCore.QTimer.singleShot(0, _cancel_dialog) + _row_remove_button(gui, "q02.IF").click() + qtbot.wait(300) # a pm-lock-update would have arrived by now + assert pm.has_param("q02.IF") + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target=f"{PM_NAME}.q02.IF", locked=True + ) + assert gui.state.locks.get("q01.IF") == PMLockBluePrint( + target=f"{PM_NAME}.q02.IF", locked=True + ) + + # the delete_item shortcut path, with the row current + source_index = gui.model.indexFromItem(_row_items(gui, "q02.IF")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + QtCore.QTimer.singleShot(0, _cancel_dialog) + gui._deleteCurrentItem() + qtbot.wait(300) + assert pm.has_param("q02.IF") + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target=f"{PM_NAME}.q02.IF", locked=True + ) + + # the Ok path: the Target is gone, its Follower's Lock with it + def _accept_dialog(): + dialog = gui.removalDialog + assert dialog is not None + dialog.button(QtWidgets.QMessageBox.StandardButton.Ok).click() + + QtCore.QTimer.singleShot(0, _accept_dialog) + _row_remove_button(gui, "q02.IF").click() + qtbot.waitUntil(lambda: not pm.has_param("q02.IF"), timeout=BROADCAST_TIMEOUT) + qtbot.waitUntil( + lambda: pm.get_lock("q01.IF") is None, timeout=BROADCAST_TIMEOUT + ) + qtbot.waitUntil( + lambda: _lock_item(gui, "q01.IF").text() == "", + timeout=BROADCAST_TIMEOUT, + ) + assert "q01.IF" not in gui.state.locks + # the closed dialog no longer sits on the GUI + assert gui.removalDialog is None + + # a parameter without Followers is removed with no dialog + gui.removeParameter("other.x") + qtbot.waitUntil(lambda: not pm.has_param("other.x"), timeout=BROADCAST_TIMEOUT) + assert gui.removalDialog is None + finally: + gui.model.stopListener() + + +def test_removing_a_target_falls_back_to_the_client_side_followers( + qtbot, pm, second_client, server_port, monkeypatch +): + """When the Server call for ``followers_of`` fails, the confirmation + falls back to the client-side list computed from the state — locked + and unlocked alike, the Targets compared through ``relative_path``: + the dialog still names the Followers, and Cancel leaves the Server + untouched.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + second_pm.lock("q01.IF", "q02.IF") + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + ), + timeout=BROADCAST_TIMEOUT, + ) + + def _raise(*args, **kwargs): + raise RuntimeError("the Server call failed") + + # the instance attribute shadows the Proxy Instrument's proxied + # method, so removeParameter's Server call fails + monkeypatch.setattr(gui.instrument, "followers_of", _raise) + + def _cancel_dialog(): + dialog = gui.removalDialog + assert dialog is not None + assert dialog.text().startswith( + "Removing q02.IF also removes the Locks of:" + ) + assert "q01.IF (locked)" in dialog.text() + dialog.button(QtWidgets.QMessageBox.StandardButton.Cancel).click() + + # the row's delete button path: the dialog is built from the + # state, and Cancel keeps the Target and the Lock untouched + QtCore.QTimer.singleShot(0, _cancel_dialog) + _row_remove_button(gui, "q02.IF").click() + qtbot.wait(300) # a pm-lock-update would have arrived by now + assert pm.has_param("q02.IF") + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target=f"{PM_NAME}.q02.IF", locked=True + ) + assert gui.state.locks.get("q01.IF") == PMLockBluePrint( + target=f"{PM_NAME}.q02.IF", locked=True + ) + assert gui.removalDialog is None + finally: + gui.model.stopListener() + + +def test_the_lock_shortcuts_arm_unlock_and_switch_tabs( + qtbot, pm, second_client, server_port +): + """Ctrl+L arms the pick for the tree's current parameter row (and + does nothing on a submodule row), Ctrl+U unlocks the current locked + Follower, and Ctrl+Shift+Y switches between the tabs.""" + second_pm = _second_parameter_manager(second_client) + _make_live_parameters(pm) + + gui = _make_gui(qtbot, pm, server_port) + try: + _wait_until_broadcasts_arrive(qtbot, gui, second_pm) + second_pm.lock("q01.IF", "q02.IF") + qtbot.waitUntil( + lambda: ( + gui.state.locks.get("q01.IF") + == PMLockBluePrint(target=f"{PM_NAME}.q02.IF", locked=True) + ), + timeout=BROADCAST_TIMEOUT, + ) + + gui.show() + qtbot.waitExposed(gui) + gui.view.expandAll() + control = QtCore.Qt.KeyboardModifier.ControlModifier + control_shift = ( + QtCore.Qt.KeyboardModifier.ControlModifier + | QtCore.Qt.KeyboardModifier.ShiftModifier + ) + + # Ctrl+L with the q01.IF row current arms the pick for it + source_index = gui.model.indexFromItem(_row_items(gui, "q01.IF")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + gui.view.setFocus() + qtbot.wait(20) + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_L, control) + assert gui.locksController.armed_follower == "q01.IF" + assert not gui.armStrip.isHidden() + gui.locksController.cancel_arm() + + # Ctrl+U on the locked Follower unlocks it on the Server. Hiding + # the armed strip hands focus to the next row editor, and the + # navigation filter's FocusIn moves the tree's current row there — + # re-establish the row the shortcut should act on. + source_index = gui.model.indexFromItem(_row_items(gui, "q01.IF")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + gui.view.setFocus() + qtbot.wait(20) + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_U, control) + qtbot.waitUntil( + lambda: pm.get_lock("q01.IF").locked is False, + timeout=BROADCAST_TIMEOUT, + ) + + # Ctrl+U on an unlocked Follower does nothing: the Lock stays + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_U, control) + qtbot.wait(300) + assert pm.get_lock("q01.IF") is not None + + # Ctrl+L on a submodule row arms nothing + source_index = gui.model.indexFromItem(_row_items(gui, "q01")[0]) + gui.view.setCurrentIndex(gui.proxyModel.mapFromSource(source_index)) + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_L, control) + assert gui.locksController.armed_follower is None + assert gui.armStrip.isHidden() + + # Ctrl+Shift+Y toggles between the tabs + assert gui.tabs.currentIndex() == 0 + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_Y, control_shift) + assert gui.tabs.currentIndex() == 1 + qtbot.keyClick(gui.view, QtCore.Qt.Key.Key_Y, control_shift) + assert gui.tabs.currentIndex() == 0 + finally: + gui.model.stopListener() + + +def test_a_parameter_update_for_an_unknown_row_recomputes_the_tints( + qtbot, pm, second_client, server_port +): + """A parameter-update or parameter-call Broadcast for a parameter the + model does not know adds the row through the base update branch; the + tints are recomputed for it too (plan task 5.6), so the new row + carries the claiming Type's tint right away instead of staying + untinted until the next recompute.""" + second_pm = _second_parameter_manager(second_client) + pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", unit="Hz") + pm.update() # the GUI's tree is built from the proxy's blueprint + + gui = _make_gui(qtbot, pm, server_port) + try: + gui.model.stopListener() + + # created while the listener is stopped: the creation Broadcast is + # lost, so the model has no q02.IF row; refreshing the GUI's own + # Proxy (without reloading the model) makes the parameter resolve + # when the update Broadcast arrives + second_pm.add_parameter("q02.IF", initial_value=2.0, unit="Hz") + second_pm.add_parameter("q03.IF", initial_value=3.0, unit="Hz") + pm.update() + assert not _row_exists(gui, "q02.IF") + assert not _row_exists(gui, "q03.IF") + + gui.model.updateParameter( + ParameterBroadcastBluePrint( + name=f"{PM_NAME}.q02.IF", + action=PARAMETER_UPDATE, + value=2.0, + unit="Hz", + ) + ) + qtbot.waitUntil(lambda: _row_exists(gui, "q02.IF"), timeout=BROADCAST_TIMEOUT) + tint = _type_tint(gui, "qubit") + assert tint is not None + for item in _row_items(gui, "q02.IF"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint + assert _row_items(gui, "q02.IF")[3].data(GUTTER_ROLE) == ["qubit"] + + # the base branch treats a parameter-call the same way: the row it + # adds is recomputed too + gui.model.updateParameter( + ParameterBroadcastBluePrint( + name=f"{PM_NAME}.q03.IF", + action=PARAMETER_CALL, + value=3.0, + unit="Hz", + ) + ) + qtbot.waitUntil(lambda: _row_exists(gui, "q03.IF"), timeout=BROADCAST_TIMEOUT) + for item in _row_items(gui, "q03.IF"): + assert item.data(QtCore.Qt.ItemDataRole.BackgroundRole) in tint + assert _row_items(gui, "q03.IF")[3].data(GUTTER_ROLE) == ["qubit"] + finally: + gui.model.stopListener() diff --git a/test/pytest/test_pm_locks.py b/test/pytest/test_pm_locks.py new file mode 100644 index 0000000..60c8278 --- /dev/null +++ b/test/pytest/test_pm_locks.py @@ -0,0 +1,1089 @@ +"""Tests for ``ManagedParameter`` and the Lock API (plan tasks 1.1–1.3). + +The first part wires two standalone ManagedParameters (a Target and a +Follower) by hand, with no Parameter Manager involved. The second part +exercises the Lock API on a local Parameter Manager (``lock``, ``unlock``, +``relock``, ``toggle_lock``, ``remove_lock``, ``get_lock``, ``list_locks``, +``followers_of``, and the ``remove_parameter`` Lock cleanup). The third +part checks the ``pm-lock-update`` Broadcasts the Lock methods emit +(D10), on a local Parameter Manager with a Broadcast sink. The last part +exercises the Lock API through a client proxy against a live Server: every +method callable over the wire, ``get_lock``/``list_locks`` deserialising +to ``PMLockBluePrint``, the pull-on-get value over the wire, and a +SubClient receiving the Broadcasts. +""" + +import logging +import re + +import pytest + +from instrumentserver.blueprints import ( + PM_LOCK_UPDATE, + ParameterBroadcastBluePrint, + PMLockBluePrint, +) +from instrumentserver.params import ( + ManagedParameter, + ParameterGroup, + ParameterManager, +) + + +def make_target_and_follower(): + """A Target and a Follower, both standalone ManagedParameters.""" + target = ManagedParameter("target", set_cmd=None, initial_value=11, unit="V") + follower = ManagedParameter("follower", set_cmd=None, initial_value=22, unit="V") + return target, follower + + +def lock_follower(follower, target, locked=True): + """Attach a Lock to the Follower directly (no manager: task 1.2 adds + the Lock API).""" + follower._target = target + follower.lock = PMLockBluePrint(target=target.path, locked=locked) + + +class CountingManagedParameter(ManagedParameter): + """ManagedParameter that counts how often its value is read.""" + + def __init__(self, name, **kwargs): + self.get_calls = 0 + super().__init__(name, **kwargs) + + def get_raw(self): + self.get_calls += 1 + return super().get_raw() + + +def test_get_redirects_to_target_while_locked(): + target, follower = make_target_and_follower() + assert follower.get() == 22 + + lock_follower(follower, target) + assert follower.locked + assert follower.get() == 11 + + # pull on get: changing the Target is enough, nothing is pushed + target.set(33) + assert follower.get() == 33 + + +def test_set_while_locked_raises_and_names_the_target(): + target, follower = make_target_and_follower() + lock_follower(follower, target) + + with pytest.raises( + ValueError, match=re.escape(f"{follower.path} is locked to {target.path}") + ): + follower.set(5) + + # the refused set changed nothing: neither the Target nor the own value + assert target.get() == 11 + assert follower.own_value() == 22 + + +def test_set_while_locked_names_dotted_full_paths_inside_a_manager(): + pm = ParameterManager(name="parameter_manager") + pm.add_parameter("q01.x", initial_value=1, unit="V") + pm.add_parameter("q02.y", initial_value=2, unit="V") + + target = pm.parameter("q01.x") + follower = pm.parameter("q02.y") + assert target.path == "parameter_manager.q01.x" + assert follower.path == "parameter_manager.q02.y" + + follower._target = target + follower.lock = PMLockBluePrint(target="parameter_manager.q01.x", locked=True) + + with pytest.raises( + ValueError, + match=re.escape("parameter_manager.q02.y is locked to parameter_manager.q01.x"), + ): + follower.set(5) + + +def test_unlocked_lock_exposes_own_value(): + target, follower = make_target_and_follower() + lock_follower(follower, target, locked=False) + + # an unlocked Lock only remembers its Target + target.set(33) + assert follower.get() == 22 + + follower.set(44) + assert follower.get() == 44 + + +def test_locking_leaves_the_own_cache_untouched(): + target, follower = make_target_and_follower() + lock_follower(follower, target) + + assert follower.get() == 11 + # the locked get answers with the Target's value but must not write it + # into the Follower's own cache + assert follower.cache.get() == 22 + assert follower.own_value() == 22 + + # unlocking exposes the own value again (ADR-0002) + follower.lock.locked = False + assert follower.get() == 22 + + +def test_snapshot_without_lock_has_no_lock_entry(): + _, follower = make_target_and_follower() + + snap = follower.snapshot(update=False) + assert snap["value"] == 22 + assert "lock" not in snap + + +def test_snapshot_while_locked_reports_target_value_and_lock(): + target, follower = make_target_and_follower() + lock_follower(follower, target) + + snap = follower.snapshot(update=False) + assert snap["value"] == 11 + assert snap["lock"] == {"target": target.path, "locked": True} + + # the own value survives in the cache + assert follower.own_value() == 22 + + +def test_snapshot_while_unlocked_reports_own_value_and_lock(): + target, follower = make_target_and_follower() + lock_follower(follower, target, locked=False) + + snap = follower.snapshot(update=False) + assert snap["value"] == 22 + assert snap["lock"] == {"target": target.path, "locked": False} + + +def test_locked_snapshot_gets_the_target_once_per_snapshot(): + target = CountingManagedParameter("target", set_cmd=None, initial_value=11) + follower = ManagedParameter("follower", set_cmd=None, initial_value=22) + lock_follower(follower, target) + + # update=True: the base snapshot asks this parameter, whose locked get + # already answers with the Target's value — no second Target get + snap = follower.snapshot(update=True) + assert snap["value"] == 11 + assert snap["lock"] == {"target": target.path, "locked": True} + assert target.get_calls == 1 + + # update=False: the Target is read directly, per its own state (D7) + snap = follower.snapshot(update=False) + assert snap["value"] == 11 + assert target.get_calls == 2 + + +def test_own_value_is_the_cached_own_value_regardless_of_state(): + target, follower = make_target_and_follower() + assert follower.own_value() == 22 + + lock_follower(follower, target) + assert follower.get() == 11 + assert follower.own_value() == 22 + + follower.lock.locked = False + assert follower.own_value() == 22 + + +def test_parameter_manager_creates_managed_parameters(): + pm = ParameterManager(name="pm_locks_unit") + pm.add_parameter("q01.x", initial_value=5, unit="V") + + assert isinstance(pm.parameter("q01.x"), ManagedParameter) + assert pm.get("q01.x") == 5 + + pm.set("q01.x", 6) + assert pm.get("q01.x") == 6 + + +# --------------------------------------------------------------------------- +# Lock API on the Parameter Manager (plan task 1.2, D9) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def pm(tmp_path, monkeypatch): + """A fresh Parameter Manager in an empty working directory, with a few + parameters to lock.""" + monkeypatch.chdir(tmp_path) + manager = ParameterManager(name="parameter_manager") + manager.add_parameter("q01.x", initial_value=1, unit="V") + manager.add_parameter("q01.y", initial_value=2, unit="V") + manager.add_parameter("q02.x", initial_value=3, unit="V") + manager.add_parameter("q02.y", initial_value=4, unit="V") + manager.add_parameter("q01Data.IF", initial_value=10, unit="Hz") + return manager + + +def test_lock_creates_a_locked_lock_and_get_pulls(pm): + pm.lock("q01.x", "q01Data.IF") + + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + assert pm.parameter("q01.x").locked + assert pm.get("q01.x") == 10 + + # pull on get: changing the Target is enough, nothing is pushed + pm.set("q01Data.IF", 20) + assert pm.get("q01.x") == 20 + + # set on the locked Follower raises, naming the full dotted paths + with pytest.raises( + ValueError, + match=re.escape( + "parameter_manager.q01.x is locked to parameter_manager.q01Data.IF" + ), + ): + pm.set("q01.x", 99) + + +def test_names_go_in_relative_and_blueprint_targets_come_out_full(pm): + pm.lock("q02.x", "q01Data.IF") + + lock = pm.get_lock("q02.x") + assert lock is not None + assert lock.target == "parameter_manager.q01Data.IF" + assert list(pm.list_locks()) == ["q02.x"] + assert pm.followers_of("q01Data.IF") == ["q02.x"] + + +def test_lock_with_unknown_follower_or_target_raises_naming_the_path(pm): + with pytest.raises(ValueError, match="Parameter 'nope.x' does not exist"): + pm.lock("nope.x", "q01Data.IF") + + with pytest.raises(ValueError, match="Parameter 'nope.IF' does not exist"): + pm.lock("q01.x", "nope.IF") + + # the failed calls left no Lock behind + assert pm.list_locks() == {} + + +def test_lock_with_two_unknown_paths_names_both(pm): + with pytest.raises(ValueError) as excinfo: + pm.lock("nope1", "nope2") + + # every offending path, not the first (rule 3) + assert str(excinfo.value) == ( + "Parameter 'nope1' does not exist; Parameter 'nope2' does not exist" + ) + assert pm.list_locks() == {} + + +@pytest.mark.parametrize( + "call", + [ + lambda pm: pm.unlock("nope.x"), + lambda pm: pm.relock("nope.x"), + lambda pm: pm.toggle_lock("nope.x"), + lambda pm: pm.remove_lock("nope.x"), + lambda pm: pm.get_lock("nope.x"), + lambda pm: pm.followers_of("nope.x"), + ], + ids=[ + "unlock", + "relock", + "toggle_lock", + "remove_lock", + "get_lock", + "followers_of", + ], +) +def test_unknown_path_raises_naming_the_path_and_changes_nothing(pm, call): + # a Lock exists, so list_locks proves the failed call changed nothing + pm.lock("q01.x", "q01Data.IF") + + with pytest.raises(ValueError, match="Parameter 'nope.x' does not exist"): + call(pm) + + assert pm.list_locks() == { + "q01.x": PMLockBluePrint(target="parameter_manager.q01Data.IF", locked=True) + } + + +def test_relock_with_a_missing_remembered_target_raises_naming_both(pm): + pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") + # hand-wire a remembered Target that no longer exists + pm.parameter("q01.x").lock = PMLockBluePrint( + target="parameter_manager.gone", locked=False + ) + + with pytest.raises( + ValueError, + match=re.escape( + "parameter_manager.q01.x remembers Target parameter_manager.gone, " + "which does not exist" + ), + ): + pm.relock("q01.x") + + # the refused relock left the Lock unlocked + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.gone", locked=False + ) + + +def test_self_lock_raises_naming_the_path(pm): + with pytest.raises( + ValueError, match="cannot lock parameter_manager.q01.x to itself" + ): + pm.lock("q01.x", "q01.x") + + assert pm.get_lock("q01.x") is None + + +def test_lock_refuses_a_two_node_cycle_and_names_every_path(pm): + pm.lock("q01.x", "q01.y") + + with pytest.raises( + ValueError, + match=re.escape( + "cannot lock parameter_manager.q01.y to parameter_manager.q01.x: " + "cycle in Lock targets: " + "parameter_manager.q01.x -> parameter_manager.q01.y" + ), + ): + pm.lock("q01.y", "q01.x") + + # the refused lock changed nothing + assert pm.get_lock("q01.y") is None + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01.y", locked=True + ) + + +def test_lock_walks_targets_regardless_of_locked_state(pm): + # q01.x carries an unlocked Lock that remembers q01Data.IF; the cycle + # walk must still see it (D7) + pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") + + with pytest.raises(ValueError, match="cycle in Lock targets"): + pm.lock("q01Data.IF", "q01.x") + + # the Target chain a -> b -> c closes on a: locking a to c is refused, + # and the message walks the cycle from the proposed Target + pm.lock("q01.y", "q01.x") + pm.lock("q02.x", "q01.y") + with pytest.raises( + ValueError, + match=re.escape( + "cannot lock parameter_manager.q01.x to parameter_manager.q02.x: " + "cycle in Lock targets: parameter_manager.q02.x -> " + "parameter_manager.q01.y -> parameter_manager.q01.x" + ), + ): + pm.lock("q01.x", "q02.x") + + +def test_lock_re_targets_an_existing_lock(pm): + pm.lock("q01.y", "q01Data.IF") + pm.lock("q01.y", "q02.x") + + assert pm.get_lock("q01.y") == PMLockBluePrint( + target="parameter_manager.q02.x", locked=True + ) + assert pm.followers_of("q01Data.IF") == [] + assert pm.followers_of("q02.x") == ["q01.y"] + assert pm.get("q01.y") == 3 + + +def test_a_failed_re_target_leaves_the_old_lock_untouched(pm): + pm.lock("q02.x", "q01Data.IF") + pm.lock("q01.y", "q02.x") # q01.y follows q02.x + + # re-targeting q02.x to q01.y would close the cycle q02.x -> q01.y -> q02.x + with pytest.raises(ValueError, match="cycle in Lock targets"): + pm.lock("q02.x", "q01.y") + + assert pm.get_lock("q02.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + assert pm.get("q02.x") == 10 + + +def test_unlock_keeps_the_target_and_exposes_the_own_value(pm): + pm.lock("q01.x", "q01Data.IF") + + pm.unlock("q01.x") + + # unlocked Lock remembers its Target (D5) + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + assert pm.get("q01.x") == 1 + pm.set("q01.x", 7) + assert pm.get("q01.x") == 7 + # an unlocked Follower still counts as a Follower + assert pm.followers_of("q01Data.IF") == ["q01.x"] + + +def test_relock_locks_to_the_remembered_target(pm): + pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") + + pm.relock("q01.x") + + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + assert pm.get("q01.x") == 10 + # the own value is still there for the next unlock + pm.unlock("q01.x") + assert pm.get("q01.x") == 1 + + +def test_relock_refuses_a_cycle_and_stays_unlocked(pm): + # The Lock API itself cannot build this state: locking q01Data.IF to + # q01.x is refused while q01.x still remembers it, even unlocked. It is + # reachable by hand-wiring (or through the GUI's remembered target, as + # the design mock's toggleLock), so relock re-validates: locking back + # to a remembered Target that now follows the Follower is refused. + pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") + q01_data_if = pm.parameter("q01Data.IF") + q01_data_if._target = pm.parameter("q01.x") + q01_data_if.lock = PMLockBluePrint(target="parameter_manager.q01.x", locked=True) + + with pytest.raises( + ValueError, + match=re.escape( + "cannot lock parameter_manager.q01.x to parameter_manager.q01Data.IF: " + "cycle in Lock targets: " + "parameter_manager.q01Data.IF -> parameter_manager.q01.x" + ), + ): + pm.relock("q01.x") + + # the refused relock left the Lock unlocked + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + + +def test_toggle_lock_switches_both_ways(pm): + pm.lock("q01.x", "q01Data.IF") + + pm.toggle_lock("q01.x") + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + assert pm.get("q01.x") == 1 + + pm.toggle_lock("q01.x") + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + assert pm.get("q01.x") == 10 + + +def test_calls_without_a_lock_raise_naming_the_path(pm): + for call in (pm.unlock, pm.relock, pm.toggle_lock, pm.remove_lock): + with pytest.raises(ValueError, match="parameter_manager.q01.x has no Lock"): + call("q01.x") + + +def test_unlock_on_an_already_unlocked_lock_is_a_logged_no_op(pm, caplog): + pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") + target_obj = pm.parameter("q01Data.IF") + follower = pm.parameter("q01.x") + + with caplog.at_level(logging.INFO): + pm.unlock("q01.x") + + # state unchanged: the Lock is still present and unlocked, still + # remembering its Target, and the own value still answers get() + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + assert follower._target is target_obj + assert pm.get("q01.x") == 1 + records = [r for r in caplog.records if r.levelno == logging.INFO] + assert len(records) == 1 + assert ( + "parameter_manager.q01.x is already unlocked; nothing to do" + in records[0].getMessage() + ) + + +def test_relock_on_an_already_locked_lock_is_a_logged_no_op(pm, caplog): + pm.lock("q01.x", "q01Data.IF") + follower = pm.parameter("q01.x") + # sentinel: relock's mutation path would re-resolve the remembered + # Target into _target and replace this reference; the no-op must not + sentinel = pm.parameter("q02.y") + follower._target = sentinel + value_before = pm.get("q01.x") + + with caplog.at_level(logging.INFO): + pm.relock("q01.x") + + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + assert follower._target is sentinel + assert pm.get("q01.x") == value_before + records = [r for r in caplog.records if r.levelno == logging.INFO] + assert len(records) == 1 + assert ( + "parameter_manager.q01.x is already locked; nothing to do" + in records[0].getMessage() + ) + + +def test_remove_lock_forgets_the_target_entirely(pm): + pm.lock("q01.x", "q01Data.IF") + + pm.remove_lock("q01.x") + + assert pm.get_lock("q01.x") is None + assert pm.list_locks() == {} + assert pm.followers_of("q01Data.IF") == [] + assert pm.get("q01.x") == 1 + pm.set("q01.x", 42) + assert pm.get("q01.x") == 42 + + +def test_get_lock_and_list_locks_without_locks(pm): + assert pm.get_lock("q01.x") is None + assert pm.list_locks() == {} + + +def test_followers_of_lists_locked_and_unlocked_followers(pm): + pm.lock("q01.x", "q01Data.IF") + pm.lock("q02.x", "q01Data.IF") + pm.unlock("q02.x") + + assert pm.followers_of("q01Data.IF") == ["q01.x", "q02.x"] + # a parameter nobody follows + assert pm.followers_of("q02.y") == [] + + +def test_chain_reads_hop_by_hop_per_own_state(pm): + pm.lock("q01.y", "q01Data.IF") # middle hop + pm.lock("q02.x", "q01.y") # end of the chain + + # both locked: q02.x pulls through q01.y to q01Data.IF + assert pm.get("q02.x") == 10 + + # q01.y unlocked: q02.x reads q01.y's own value (D7) + pm.unlock("q01.y") + pm.set("q01.y", 5) + assert pm.get("q02.x") == 5 + assert pm.followers_of("q01.y") == ["q02.x"] + + # q01.y locked again: the chain pulls through once more + pm.relock("q01.y") + assert pm.get("q02.x") == 10 + + +def test_remove_parameter_removes_every_lock_pointing_at_it(pm): + pm.lock("q01.x", "q01Data.IF") + pm.lock("q02.x", "q01Data.IF") + pm.unlock("q02.x") + + pm.remove_parameter("q01Data.IF") + + # both Followers are plain parameters again (D3), whatever the state was + assert pm.get_lock("q01.x") is None + assert pm.get_lock("q02.x") is None + assert pm.list_locks() == {} + assert pm.get("q01.x") == 1 + assert pm.get("q02.x") == 3 + pm.set("q01.x", 11) + assert pm.get("q01.x") == 11 + + +def test_remove_parameter_leaves_unrelated_locks_alone(pm): + pm.lock("q01.x", "q01Data.IF") + pm.lock("q02.x", "q02.y") + + pm.remove_parameter("q02.y") # Target of q02.x only + + assert pm.get_lock("q02.x") is None + # q01.x still follows q01Data.IF + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + assert pm.get("q01.x") == 10 + + +def test_remove_parameter_of_a_chain_middle(pm): + pm.lock("q01.y", "q01Data.IF") # Follower of q01Data.IF ... + pm.lock("q02.x", "q01.y") # ... and Target of q02.x + + pm.remove_parameter("q01.y") + + # q02.x followed q01.y, which is gone: plain parameter again + assert pm.get_lock("q02.x") is None + assert pm.get("q02.x") == 3 + # q01Data.IF is untouched and now has no Followers + assert pm.get("q01Data.IF") == 10 + assert pm.followers_of("q01Data.IF") == [] + + +def test_remove_all_parameters_clears_every_lock(pm): + pm.lock("q01.x", "q01Data.IF") + pm.lock("q02.x", "q01.y") + + pm.remove_all_parameters() + + assert pm.list() == [] + assert pm.list_locks() == {} + + +def test_lock_with_a_plain_group_parameter(pm): + # a plain qcodes Parameter (no lock, no path) can only end up inside a + # Parameter Manager through direct, unrouted creation — legacy or + # foreign code. The Lock API must still handle it. + pm.q01._add_own_parameter("plain", set_cmd=None, initial_value=7) + + # it cannot carry a Lock + with pytest.raises( + ValueError, match="parameter_manager.q01.plain cannot carry a Lock" + ): + pm.lock("q01.plain", "q01.x") + assert pm.get_lock("q01.plain") is None + + # but the Target knows nothing (D3): following it works + pm.lock("q01.x", "q01.plain") + assert pm.get("q01.x") == 7 + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01.plain", locked=True + ) + assert pm.followers_of("q01.plain") == ["q01.x"] + # a plain Parameter never shows up as a Follower + assert "q01.plain" not in pm.list_locks() + + +def test_group_remove_parameter_delegates_to_the_root(pm): + pm.lock("q01.x", "q02.y") + + pm.q02.remove_parameter("y") + + # the Lock pointing at the removed Target went with it (D3), through + # the root's cleanup + assert pm.list_locks() == {} + assert pm.get_lock("q01.x") is None + assert pm.get("q01.x") == 1 + pm.set("q01.x", 5) + assert pm.get("q01.x") == 5 + + +def test_group_add_parameter_creates_a_managed_parameter(pm): + pm.q01.add_parameter("z", initial_value=1) + + param = pm.parameter("q01.z") + assert isinstance(param, ManagedParameter) + assert param.path == "parameter_manager.q01.z" + # it can carry a Lock + pm.lock("q01.z", "q01Data.IF") + assert pm.get("q01.z") == 10 + + +def test_nested_group_add_parameter_creates_a_managed_parameter(pm): + pm.add_parameter("q01.ro.IF", initial_value=2) # creates the depth-2 group + + pm.q01.ro.add_parameter("gain", initial_value=3) + + param = pm.parameter("q01.ro.gain") + assert isinstance(param, ManagedParameter) + assert param.path == "parameter_manager.q01.ro.gain" + pm.lock("q01.ro.gain", "q01.ro.IF") + assert pm.get("q01.ro.gain") == 2 + + +def test_standalone_group_keeps_plain_parameters(): + solo = ParameterGroup("solo_group") + solo.add_parameter("p", initial_value=1) + + assert solo._root is None + assert not isinstance(solo.parameter("p"), ManagedParameter) + + +def test_target_paths_must_be_relative_to_the_manager(pm): + # rule 4: paths are relative to the Parameter Manager. A full-form + # path and a foreign root both name a parameter this manager cannot + # resolve (D8: Targets only inside the same Parameter Manager). + with pytest.raises( + ValueError, + match=re.escape("Parameter 'parameter_manager.q01.y' does not exist"), + ): + pm.lock("q01.x", "parameter_manager.q01.y") + + with pytest.raises( + ValueError, match=re.escape("Parameter 'other.z' does not exist") + ): + pm.lock("q01.x", "other.z") + + assert pm.list_locks() == {} + + +# --------------------------------------------------------------------------- +# pm-lock-update Broadcasts (plan task 1.3, D10) +# +# One Broadcast per affected Follower, name = full Follower path, value = +# its PMLockBluePrint, or None when its Lock was removed. Emitted by every +# Lock method that changes a Lock and by remove_parameter when deleting a +# Target drops Locks; the logged no-op paths and failed validations emit +# nothing (decided during 1.3). +# --------------------------------------------------------------------------- + + +@pytest.fixture +def pm_with_sink(pm): + """The Lock API fixture with a Broadcast sink attached, recording + every Broadcast the Parameter Manager emits.""" + received = [] + pm.add_broadcast_sink(received.append) + return pm, received + + +def test_lock_emits_one_pm_lock_update_naming_the_follower(pm_with_sink): + pm, received = pm_with_sink + + pm.lock("q01.x", "q01Data.IF") + + assert len(received) == 1 + bp = received[0] + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "parameter_manager.q01.x" + assert bp.action == PM_LOCK_UPDATE + assert bp.value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + +def test_re_targeting_a_lock_emits_one_pm_lock_update_with_the_new_target( + pm_with_sink, +): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + received.clear() + + pm.lock("q01.x", "q01.y") # re-targets the existing Lock (D9) + + assert len(received) == 1 + bp = received[0] + assert bp.name == "parameter_manager.q01.x" + assert bp.action == PM_LOCK_UPDATE + assert bp.value == PMLockBluePrint(target="parameter_manager.q01.y", locked=True) + assert pm.get("q01.x") == 2 # the Follower now pulls from q01.y + + +def test_unlock_emits_pm_lock_update_with_the_unlocked_lock(pm_with_sink): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + received.clear() + + pm.unlock("q01.x") + + assert len(received) == 1 + bp = received[0] + assert bp.name == "parameter_manager.q01.x" + assert bp.action == PM_LOCK_UPDATE + assert bp.value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + + +def test_relock_emits_pm_lock_update_with_the_locked_lock(pm_with_sink): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") + received.clear() + + pm.relock("q01.x") + + assert len(received) == 1 + bp = received[0] + assert bp.name == "parameter_manager.q01.x" + assert bp.action == PM_LOCK_UPDATE + assert bp.value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + +def test_toggle_lock_emits_one_broadcast_per_state_change(pm_with_sink): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + received.clear() + + pm.toggle_lock("q01.x") + assert len(received) == 1 + assert received[0].name == "parameter_manager.q01.x" + assert received[0].value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=False + ) + + pm.toggle_lock("q01.x") + assert len(received) == 2 + assert received[1].value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + +def test_broadcast_payloads_are_independent_of_the_stored_lock(pm_with_sink): + # unlock and relock broadcast a snapshot, not the parameter's own Lock + # record: a sink that keeps payloads must still see each Broadcast's + # state at emit time after the Lock is toggled again + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + received.clear() + + pm.toggle_lock("q01.x") # unlock + pm.toggle_lock("q01.x") # lock again + + assert len(received) == 2 + assert received[0].value.locked is False + assert received[1].value.locked is True + assert received[0].value.locked != received[1].value.locked + assert received[0].value is not received[1].value + # the stored record is untouched by the broadcasting + assert pm.get_lock("q01.x") == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + +def test_remove_lock_emits_pm_lock_update_with_none(pm_with_sink): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + received.clear() + + pm.remove_lock("q01.x") + + assert len(received) == 1 + bp = received[0] + assert bp.name == "parameter_manager.q01.x" + assert bp.action == PM_LOCK_UPDATE + assert bp.value is None + + +def test_noop_unlock_and_relock_emit_nothing(pm_with_sink, caplog): + # decided during 1.3: the logged no-op paths affect no Follower, + # so they emit no Broadcast + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") # locked + pm.lock("q02.x", "q01Data.IF") # locked + pm.unlock("q02.x") # unlocked + received.clear() + + with caplog.at_level(logging.INFO): + pm.relock("q01.x") # already locked: no-op + pm.unlock("q02.x") # already unlocked: no-op + + assert received == [] + assert "already locked" in caplog.text + assert "already unlocked" in caplog.text + + +def test_failed_lock_validations_emit_nothing(pm_with_sink): + pm, received = pm_with_sink + + # validate-then-mutate: a refused call must not broadcast either + with pytest.raises(ValueError): + pm.lock("nope", "q01Data.IF") + with pytest.raises(ValueError): + pm.lock("q01.x", "q01.x") # self-lock + with pytest.raises(ValueError): + pm.relock("q02.y") # no Lock + with pytest.raises(ValueError): + pm.toggle_lock("q02.y") # no Lock + assert received == [] + + pm.lock("q01.x", "q01Data.IF") + pm.unlock("q01.x") + received.clear() + with pytest.raises(ValueError): + pm.lock("q01Data.IF", "q01.x") # would close a cycle + with pytest.raises(ValueError): + pm.unlock("q02.y") # no Lock + with pytest.raises(ValueError): + pm.relock("q02.y") # no Lock + with pytest.raises(ValueError): + pm.toggle_lock("q02.y") # no Lock + with pytest.raises(ValueError): + pm.remove_lock("q02.y") # no Lock + # a relock that would close a cycle is refused as well (D7) + pm.parameter("q01Data.IF")._target = pm.parameter("q01.x") + pm.parameter("q01Data.IF").lock = PMLockBluePrint( + target="parameter_manager.q01.x", locked=True + ) + with pytest.raises(ValueError, match="cycle in Lock targets"): + pm.relock("q01.x") + + assert received == [] + + +def test_remove_parameter_emits_one_none_per_dropped_lock(pm_with_sink): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") + pm.lock("q02.x", "q01Data.IF") + pm.unlock("q02.x") # an unlocked Lock is dropped all the same + received.clear() + + pm.remove_parameter("q01Data.IF") + + assert len(received) == 2 + names = {bp.name for bp in received} + assert names == {"parameter_manager.q01.x", "parameter_manager.q02.x"} + for bp in received: + assert bp.action == PM_LOCK_UPDATE + assert bp.value is None + + +def test_remove_parameter_without_dropped_locks_emits_nothing(pm_with_sink): + pm, received = pm_with_sink + pm.lock("q01.x", "q01Data.IF") # q01.x is a Follower, not a Target + received.clear() + + # removing a Follower drops no Locks: its own Lock disappears with it, + # and the parameter-deletion Broadcast is the Server's business + pm.remove_parameter("q01.x") + assert received == [] + + pm.remove_parameter("q02.y") # unrelated parameter + assert received == [] + + +# --------------------------------------------------------------------------- +# Lock API through a client proxy against a live Server (plan task 1.3) +# +# The Server registers itself as a Broadcast sink on the Parameter Manager +# (task 0.3), so every Lock method call over the wire also emits its +# pm-lock-update on the PUB socket. The server-side Parameter Manager is +# shared by all tests of this module, so every test removes the parameters +# it created again. +# --------------------------------------------------------------------------- + +PROXY_FOLLOWER = "q02.x" +PROXY_TARGET = "q01Data.IF" + + +def _add_proxy_params(params): + """Create the two parameters the proxy Lock tests use, replacing any + leftovers from an earlier test of this module.""" + _remove_proxy_params(params) + params.add_parameter(PROXY_FOLLOWER, initial_value=3, unit="V") + params.add_parameter(PROXY_TARGET, initial_value=10, unit="Hz") + + +def _remove_proxy_params(params): + for name in (PROXY_FOLLOWER, PROXY_TARGET): + if params.has_param(name): + params.remove_parameter(name) + + +def test_every_lock_method_is_callable_through_the_proxy(param_manager): + cli, params = param_manager + _add_proxy_params(params) + try: + params.lock(PROXY_FOLLOWER, PROXY_TARGET) + assert params.get_lock(PROXY_FOLLOWER) == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + params.unlock(PROXY_FOLLOWER) + assert params.get_lock(PROXY_FOLLOWER).locked is False + + params.relock(PROXY_FOLLOWER) + assert params.get_lock(PROXY_FOLLOWER).locked is True + + params.toggle_lock(PROXY_FOLLOWER) + assert params.get_lock(PROXY_FOLLOWER).locked is False + params.toggle_lock(PROXY_FOLLOWER) + assert params.get_lock(PROXY_FOLLOWER).locked is True + + assert params.followers_of(PROXY_TARGET) == [PROXY_FOLLOWER] + assert list(params.list_locks()) == [PROXY_FOLLOWER] + + params.remove_lock(PROXY_FOLLOWER) + assert params.get_lock(PROXY_FOLLOWER) is None + assert params.list_locks() == {} + assert params.followers_of(PROXY_TARGET) == [] + finally: + _remove_proxy_params(params) + + +def test_get_lock_and_list_locks_deserialise_to_pm_lock_blueprint(param_manager): + cli, params = param_manager + _add_proxy_params(params) + try: + params.lock(PROXY_FOLLOWER, PROXY_TARGET) + + lock_bp = params.get_lock(PROXY_FOLLOWER) + assert isinstance(lock_bp, PMLockBluePrint) + assert lock_bp == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + locks = params.list_locks() + assert isinstance(locks, dict) + assert isinstance(locks[PROXY_FOLLOWER], PMLockBluePrint) + assert locks == { + PROXY_FOLLOWER: PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + } + finally: + _remove_proxy_params(params) + + +def test_locked_follower_answers_get_with_the_target_value_over_the_wire( + param_manager, +): + cli, params = param_manager + _add_proxy_params(params) + try: + params.lock(PROXY_FOLLOWER, PROXY_TARGET) + + # the named check: pm.q02.x() returns the Target's value + assert params.q02.x() == 10 + + # pull on get: changing the Target is enough, nothing is pushed + params.q01Data.IF.set(20) + assert params.q02.x() == 20 + + # unlocking exposes the Follower's own value again + params.unlock(PROXY_FOLLOWER) + assert params.q02.x() == 3 + finally: + _remove_proxy_params(params) + + +def test_subclient_receives_pm_lock_update_and_none_after_remove_lock( + param_manager, server_port, capture_broadcasts, wait_for_broadcasts +): + cli, params = param_manager + _add_proxy_params(params) + try: + with capture_broadcasts(["parameter_manager"], server_port + 1) as received: + params.lock(PROXY_FOLLOWER, PROXY_TARGET) + wait_for_broadcasts(received) + + assert len(received) == 1 + bp = received[0] + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "parameter_manager.q02.x" + assert bp.action == PM_LOCK_UPDATE + assert isinstance(bp.value, PMLockBluePrint) + assert bp.value == PMLockBluePrint( + target="parameter_manager.q01Data.IF", locked=True + ) + + params.remove_lock(PROXY_FOLLOWER) + wait_for_broadcasts(received, n=2) + + assert len(received) == 2 + removed = received[1] + assert removed.name == "parameter_manager.q02.x" + assert removed.action == PM_LOCK_UPDATE + assert removed.value is None + finally: + _remove_proxy_params(params) diff --git a/test/pytest/test_pm_persistence.py b/test/pytest/test_pm_persistence.py new file mode 100644 index 0000000..e6700c8 --- /dev/null +++ b/test/pytest/test_pm_persistence.py @@ -0,0 +1,1405 @@ +"""Tests for the Parameter Manager's version-2 persistence (plan tasks +4.1, 4.2 and 4.3). + +The writer part checks the document :meth:`ParameterManager.toParamDict` +produces (plan decision D19): ``version`` 2, ``parameters`` keyed by full +dotted paths with the own values, the per-Follower ``lock`` entries and the +``types`` section, the file dump format, and validity against +``schemas/parameter_manager_v2.json``. The reader part checks +:meth:`ParameterManager.fromParamDict`/:meth:`ParameterManager.fromFile`: +the legacy flat map still loads (parameters only), a version-2 document is +validated as a whole — refused with every problem named, state untouched — +and then loads in D20's order (the Locks of the listed parameters, the +parameters, the Types without Instance side effects, the Locks), with the +load's Broadcasts emitted once after it succeeded. The profile part checks +:meth:`ParameterManager.switch_to_profile` (D20): the leaving profile is +saved as a version-2 document, then parameters, Types and Locks are +cleared (:meth:`ParameterManager._clear_all`, whose Broadcasts are the +``pm-type-update`` ``None`` payloads of the removed Types first, then +whatever the parameter removal emits), then the new profile loads — a +version-2 document or a legacy flat map — so switching between profiles +round-trips the Types, the Locks and the Globals parameters, switching to +a profile without any leaves none behind, an unknown profile raises and +saves nothing, and ``refresh_profiles``/``list_profiles`` are unchanged. +All tests are unit tests on a local Parameter Manager with no Server +involved. +""" + +import json +import re +from pathlib import Path + +import pytest +from jsonschema import ValidationError, validate + +from instrumentserver import PM_V2_SCHEMA_PATH +from instrumentserver.blueprints import ( + PARAMETER_CREATION, + PARAMETER_DELETION, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + PMLockBluePrint, +) +from instrumentserver.params import ManagedParameter, ParameterManager + +FIXTURES = Path(__file__).parent / "fixtures" + + +def make_populated_manager(name="params"): + """A local Parameter Manager with a nested parameter and Types with + entries and a Nested Type, covering the writer's ``types`` section.""" + pm = ParameterManager(name=name) + pm.add_parameter(name="my_param", initial_value=123, unit="M") + pm.add_parameter(name="nested_param.child", initial_value=456, unit="a") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_type("readout") + pm.add_type_parameter("readout", "power", default=-10, unit="dBm") + pm.add_nested_type("qubit", "readout", "readout") + return pm + + +# --------------------------------------------------------------------------- +# Writer: the version-2 document (D19) +# --------------------------------------------------------------------------- + + +def test_document_has_version_two_and_full_path_keys(tmp_path): + pm = make_populated_manager() + pm.workingDirectory = tmp_path + + doc = pm.toParamDict() + + assert doc["version"] == 2 + assert set(doc["parameters"]) == { + "params.my_param", + "params.nested_param.child", + } + assert doc["parameters"]["params.my_param"] == {"value": 123, "unit": "M"} + assert doc["parameters"]["params.nested_param.child"] == { + "value": 456, + "unit": "a", + } + + +def test_simple_format_argument_is_ignored_by_the_writer(tmp_path): + """A version-2 document always stores the per-parameter dict, whatever + ``simpleFormat`` says.""" + pm = make_populated_manager() + pm.workingDirectory = tmp_path + + assert pm.toParamDict(simpleFormat=True) == pm.toParamDict() + + +def test_include_meta_selects_the_per_parameter_metadata(tmp_path): + """``includeMeta`` still selects the per-parameter metadata besides + ``value``; a Follower carries its ``lock`` entry whatever + ``includeMeta`` selects.""" + pm = make_populated_manager() + pm.workingDirectory = tmp_path + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + + doc = pm.toParamDict(includeMeta=[]) + assert doc["parameters"]["params.my_param"] == {"value": 123} + assert doc["parameters"]["params.q01.IF"]["lock"] == { + "target": "params.q01Data.IF", + "locked": True, + } + + doc = pm.toParamDict(includeMeta=["unit", "label"]) + entry = doc["parameters"]["params.my_param"] + assert entry["value"] == 123 + assert entry["unit"] == "M" + assert "label" in entry + + +def test_locked_follower_saves_its_own_value_and_the_lock(tmp_path): + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + + # pull on get: the live Follower answers with the Target's value + assert pm.get("q01.IF") == 42e6 + + doc = pm.toParamDict() + entry = doc["parameters"]["params.q01.IF"] + assert entry["value"] == 101735237.0 + assert entry["lock"] == {"target": "params.q01Data.IF", "locked": True} + # the Target itself carries no Lock and so no lock entry + assert "lock" not in doc["parameters"]["params.q01Data.IF"] + + +def test_unlocked_follower_stores_locked_false(tmp_path): + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + pm.unlock("q01.IF") + + entry = pm.toParamDict()["parameters"]["params.q01.IF"] + assert entry["value"] == 101735237.0 + assert entry["lock"] == {"target": "params.q01Data.IF", "locked": False} + + +def test_parameter_without_a_lock_has_no_lock_key(tmp_path): + pm = make_populated_manager() + pm.workingDirectory = tmp_path + + for path, entry in pm.toParamDict()["parameters"].items(): + assert "lock" not in entry, path + + +def test_globals_parameter_is_saved(tmp_path): + """A Globals parameter is saved like any other (D18), with its own + value and unit.""" + pm = make_populated_manager() + pm.workingDirectory = tmp_path + # declaring the Type Lock creates the default Globals Target on demand + pm.lock_type_parameter("qubit", "octave_gain") + pm.set("_globals.qubit.octave_gain", 33) + + entry = pm.toParamDict()["parameters"]["params._globals.qubit.octave_gain"] + assert entry == {"value": 33, "unit": "dB"} + + +def test_types_section_holds_every_type_with_entries_and_nested(tmp_path): + pm = make_populated_manager() + pm.workingDirectory = tmp_path + + types = pm.toParamDict()["types"] + assert types == { + "qubit": { + "parameters": { + "IF": {"default": None, "unit": "Hz", "target": None}, + "octave_gain": {"default": 10, "unit": "dB", "target": None}, + }, + "nested": {"readout": "readout"}, + }, + "readout": { + "parameters": { + "power": {"default": -10, "unit": "dBm", "target": None}, + }, + "nested": {}, + }, + } + + +def test_type_lock_target_is_stored_in_full_form(tmp_path): + pm = make_populated_manager() + pm.workingDirectory = tmp_path + pm.lock_type_parameter("qubit", "octave_gain") + + doc = pm.toParamDict() + entry = doc["types"]["qubit"]["parameters"]["octave_gain"] + assert entry["target"] == "params._globals.qubit.octave_gain" + # declaring the Type Lock created the default Globals Target on demand; + # with no Instance, no parameter entry carries a lock + assert doc["parameters"]["params._globals.qubit.octave_gain"]["value"] == 10 + for parameter_entry in doc["parameters"].values(): + assert "lock" not in parameter_entry + + +def test_no_types_give_an_empty_types_section(tmp_path): + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + pm.add_parameter(name="my_param", initial_value=123, unit="M") + + assert pm.toParamDict()["types"] == {} + + +def test_written_file_is_valid_against_the_v2_schema(tmp_path): + """The file a toFile call writes is valid against + schemas/parameter_manager_v2.json — with Followers in both Lock + states, a Globals parameter and a full types section in it.""" + pm = make_populated_manager() + pm.workingDirectory = tmp_path + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + pm.lock_type_parameter("qubit", "octave_gain") + pm.unlock("q01.IF") + + file_path = tmp_path / "parameter_manager-params.json" + pm.toFile(str(file_path)) + with open(file_path) as f: + written = json.load(f) + + with open(PM_V2_SCHEMA_PATH) as f: + schema = json.load(f) + validate(written, schema) + + +def test_file_is_dumped_with_indent_two_and_sorted_keys(tmp_path): + pm = make_populated_manager() + pm.workingDirectory = tmp_path + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + + doc = pm.toParamDict() + file_path = tmp_path / "parameter_manager-params.json" + pm.toFile(str(file_path)) + + assert file_path.read_text() == json.dumps(doc, indent=2, sort_keys=True) + + +# --------------------------------------------------------------------------- +# Reader shim (4.1): legacy flat map, version-2 parameters, refusals +# --------------------------------------------------------------------------- + + +def test_legacy_flat_file_still_loads(tmp_path): + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + pm.add_parameter(name="old", initial_value=0, unit="u") + + legacy_path = tmp_path / "legacy.json" + legacy_path.write_text(json.dumps({"params.legacy": {"value": 5, "unit": "V"}})) + pm.fromFile(str(legacy_path)) + + assert pm.legacy() == 5 + assert pm.legacy.unit == "V" + # deleteMissing semantics of the legacy reader are unchanged + assert not pm.has_param("old") + + +def test_legacy_simple_format_file_still_loads(tmp_path): + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + + legacy_path = tmp_path / "legacy_simple.json" + legacy_path.write_text(json.dumps({"params.sp": 7})) + pm.fromFile(str(legacy_path)) + + assert pm.sp() == 7 + + +def test_legacy_flat_file_creates_a_globals_parameter_on_load(tmp_path): + """A legacy flat file holding a ``_globals.*`` key creates the Globals + parameter on load, through the internal creation path: create-on-load + is the intended behaviour for both file formats (D18; TEST_AUDIT.md, + "Profiles — loading Globals parameters").""" + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + + legacy_path = tmp_path / "legacy_globals.json" + legacy_path.write_text( + json.dumps({"params._globals.x.y": {"value": 1, "unit": "u"}}) + ) + pm.fromFile(str(legacy_path)) + + assert pm.has_param("_globals.x.y") + assert pm.get("_globals.x.y") == 1 + param = pm.parameter("_globals.x.y") + assert param.unit == "u" + assert isinstance(param, ManagedParameter) + assert param.path == "params._globals.x.y" + + +def test_version_two_file_round_trips_through_from_file(tmp_path, monkeypatch): + """A version-2 file written by toFile loads its parameters back — + values and units — through fromFile.""" + monkeypatch.chdir(tmp_path) + pm = make_populated_manager() + pm.toFile(name="params") + + pm2 = ParameterManager(name="params") + + assert pm2.my_param() == 123 + assert pm2.my_param.unit == "M" + assert pm2.nested_param.child() == 456 + assert pm2.nested_param.child.unit == "a" + + +def test_globals_parameter_round_trips_through_the_internal_path(tmp_path): + """A saved Globals parameter loads back: the reader creates it through + the internal creation path, since the public add_parameter refuses the + Globals name (D18; TEST_AUDIT.md, "Profiles — loading Globals + parameters").""" + pm = make_populated_manager() + pm.workingDirectory = tmp_path + pm.lock_type_parameter("qubit", "octave_gain") + pm.set("_globals.qubit.octave_gain", 33) + pm.toFile(tmp_path, "globals") + + pm2 = ParameterManager(name="params") + pm2.fromFile(str(tmp_path / "parameter_manager-globals.json")) + + assert pm2.has_param("_globals.qubit.octave_gain") + assert pm2.get("_globals.qubit.octave_gain") == 33 + assert pm2.parameter("_globals.qubit.octave_gain").unit == "dB" + + +def test_delete_missing_semantics_are_unchanged_on_a_version_two_load(tmp_path): + pm = ParameterManager(name="params") + pm.workingDirectory = tmp_path + pm.add_parameter(name="a", initial_value=1, unit="u") + pm.add_parameter(name="b", initial_value=2, unit="v") + pm.toFile(tmp_path, "dm") + + pm.add_parameter(name="c", initial_value=3, unit="w") + pm.fromFile(str(tmp_path / "parameter_manager-dm.json")) + assert pm.a() == 1 + assert pm.b() == 2 + assert not pm.has_param("c") + + pm.add_parameter(name="c", initial_value=3, unit="w") + with open(tmp_path / "parameter_manager-dm.json") as f: + doc = json.load(f) + pm.fromParamDict(doc, deleteMissing=False) + assert pm.has_param("c") + assert pm.c() == 3 + + +def test_parameters_load_with_own_values_from_a_v2_file_with_locks_and_types(tmp_path): + """A version-2 file may carry ``lock`` entries and a ``types`` + section; the parameters still load with their own values and units.""" + pm = make_populated_manager() + pm.workingDirectory = tmp_path + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + pm.lock_type_parameter("qubit", "octave_gain") + pm.unlock("q01.IF") + pm.toFile(tmp_path, "shim") + + pm2 = ParameterManager(name="params") + pm2.fromFile(str(tmp_path / "parameter_manager-shim.json")) + + assert pm2.get("q01.IF") == 101735237.0 + assert pm2.parameter("q01.IF").unit == "Hz" + assert pm2.get("q01Data.IF") == 42e6 + assert pm2.has_param("_globals.qubit.octave_gain") + + +def test_unsupported_version_raises_value_error_naming_it(): + pm = ParameterManager(name="params") + + with pytest.raises(ValueError, match=re.escape("3")): + pm.fromParamDict({"version": 3, "parameters": {}, "types": {}}) + with pytest.raises(ValueError, match=re.escape("'2'")): + pm.fromParamDict({"version": "2", "parameters": {}, "types": {}}) + # nothing was loaded into the Parameter Manager + assert pm.list() == [] + + +def test_invalid_document_is_refused_by_the_schema(): + pm = ParameterManager(name="params") + pm.add_parameter(name="a", initial_value=1, unit="u") + pm.add_parameter(name="b", initial_value=2, unit="v") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + good = pm.toParamDict() + + lock_missing_locked = json.loads(json.dumps(good)) + lock_missing_locked["parameters"]["params.a"]["lock"] = {"target": "params.b"} + with pytest.raises(ValidationError): + pm.fromParamDict(lock_missing_locked) + + entry_without_value = json.loads(json.dumps(good)) + del entry_without_value["parameters"]["params.b"]["value"] + with pytest.raises(ValidationError): + pm.fromParamDict(entry_without_value) + + # a per-parameter key the v2 schema does not know is refused by the + # parameters.json leg of validateParameterManagerV2 + bad_vals = json.loads(json.dumps(good)) + bad_vals["parameters"]["params.a"]["vals"] = 42 + with pytest.raises(ValidationError): + pm.fromParamDict(bad_vals) + + # the top-level keys of the version-2 document are required + without_types = json.loads(json.dumps(good)) + del without_types["types"] + with pytest.raises(ValidationError): + pm.fromParamDict(without_types) + + # a Type entry carries exactly default, unit and target + type_entry_missing_target = json.loads(json.dumps(good)) + del type_entry_missing_target["types"]["qubit"]["parameters"]["IF"]["target"] + with pytest.raises(ValidationError): + pm.fromParamDict(type_entry_missing_target) + + # the refused documents changed nothing + assert pm.a() == 1 + assert pm.b() == 2 + assert pm.list_types() == ["qubit"] + + +# --------------------------------------------------------------------------- +# Reader (4.2): the version-2 load in D20's order +# --------------------------------------------------------------------------- + + +def make_lock_and_type_manager(name="params"): + """A Parameter Manager exercising the whole version-2 document: Types + (one nesting another), Type Locks with the default Globals Target and + an explicit Target, Locks locked and unlocked, a chain, own values + differing from the Target values, and a Globals parameter. The Types + are completed before the parameters exist, so no submodule carries + the whole shape and none is an Instance (D12).""" + pm = ParameterManager(name=name) + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_type("readout") + pm.add_type_parameter("readout", "power", default=-10, unit="dBm") + pm.add_nested_type("qubit", "readout", "readout") + # the Type Lock with the default Globals Target, which creates the + # Globals parameter on demand + pm.lock_type_parameter("qubit", "octave_gain") + pm.add_parameter(name="q01.IF", initial_value=101735237.0, unit="Hz") + pm.add_parameter(name="q01Data.IF", initial_value=42e6, unit="Hz") + pm.add_parameter(name="q02.IF", initial_value=101735238.0, unit="Hz") + pm.add_parameter(name="q03.sp", initial_value=1.5, unit="V") + # the Type Lock with an explicit Target + pm.lock_type_parameter("qubit", "IF", target="q01Data.IF") + # a locked chain q03.sp -> q02.IF -> q01.IF -> q01Data.IF, with the + # last hop unlocked, and own values differing from the Target's + pm.lock("q01.IF", "q01Data.IF") + pm.lock("q02.IF", "q01.IF") + pm.lock("q03.sp", "q02.IF") + pm.unlock("q03.sp") + return pm + + +def test_legacy_fixture_file_loads_values_and_units(): + """The checked-in legacy fixture (a flat map with a nested path and a + unit) loads through fromFile with values and units.""" + pm = ParameterManager(name="parameter_manager") + pm.fromFile(str(FIXTURES / "parameter_manager-legacy.json")) + + assert pm.my_param() == 123 + assert pm.my_param.unit == "M" + assert pm.nested_param.child() == 456 + assert pm.nested_param.child.unit == "a" + + +def test_legacy_load_leaves_types_and_locks_untouched(): + """The legacy flat map loads parameters only. The locked parameter is + not listed (a legacy load sets every listed parameter, and a locked + Follower refuses set, D6), so with ``deleteMissing=False`` the load + touches neither the Lock nor the Types.""" + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + pm.add_parameter("b", initial_value=2, unit="v") + pm.lock("a", "b") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + + pm.fromParamDict({"params.b": {"value": 22, "unit": "v"}}, deleteMissing=False) + + assert pm.get("b") == 22 + assert pm.parameter("a").own_value() == 1 + assert pm.get_lock("a") == PMLockBluePrint(target="params.b", locked=True) + assert pm.list_types() == ["qubit"] + + +def test_version_two_document_round_trips_exactly(tmp_path): + """A manager with Types, Type Locks, Locks in both states, a chain and + a Globals parameter saves, loads into a fresh manager, and both + produce the same document; the loaded Followers pull on get and keep + their own values.""" + pm = make_lock_and_type_manager() + pm.toFile(str(tmp_path / "parameter_manager-rt.json")) + + pm2 = ParameterManager(name="params") + pm2.fromFile(str(tmp_path / "parameter_manager-rt.json")) + + assert pm2.toParamDict() == pm.toParamDict() + # no Instance side effects: q01 carries only IF and is not completed + # into an Instance by the loaded Type (D20) + assert not pm2.has_param("q01.octave_gain") + assert pm2.instances_of("qubit") == [] + # pull on get through the chain: q03.sp is unlocked (its own value), + # q02.IF is locked and reads q01.IF, which is locked and reads the + # Target q01Data.IF (D7) + assert pm2.get("q02.IF") == 42e6 + assert pm2.parameter("q02.IF").own_value() == 101735238.0 + assert pm2.get("q03.sp") == 1.5 + assert pm2.get_lock("q03.sp") == PMLockBluePrint( + target="params.q02.IF", locked=False + ) + assert pm2.get_lock("q01.IF") == PMLockBluePrint( + target="params.q01Data.IF", locked=True + ) + + +def test_missing_targets_are_all_named_and_change_nothing(): + """A document whose Lock Target and Type Lock Target point at absent + keys is refused with every missing Target named — both kinds, in one + message — leaving the previous state intact and emitting nothing.""" + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + pm.add_parameter("b", initial_value=2, unit="v") + pm.add_parameter("q01.IF", initial_value=1e9, unit="Hz") + pm.lock("a", "b") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + + before_list = pm.list() + before_values = {path: pm.get(path) for path in before_list} + before_locks = pm.list_locks() + before_types = pm.list_types() + before_type = pm.get_type("qubit") + + received = [] + pm.add_broadcast_sink(received.append) + + doc = { + "version": 2, + "parameters": { + "params.a": { + "value": 10, + "unit": "u", + "lock": {"target": "params.ghost1", "locked": True}, + }, + "params.b": {"value": 20, "unit": "v"}, + "params.q01.IF": {"value": 2e9, "unit": "Hz"}, + }, + "types": { + "qubit": { + "parameters": { + "IF": { + "default": None, + "unit": "Hz", + "target": "params.ghost2", + } + }, + "nested": {}, + } + }, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc) + + message = str(excinfo.value) + assert "params.ghost1" in message + assert "params.ghost2" in message + assert "the Lock on Follower 'params.a'" in message + assert "the Type Lock on 'qubit.IF'" in message + + # the previous state is untouched and nothing was emitted + assert pm.list() == before_list + assert {path: pm.get(path) for path in before_list} == before_values + assert pm.list_locks() == before_locks + assert pm.list_types() == before_types + assert pm.get_type("qubit") == before_type + assert received == [] + + +def test_invalid_type_names_in_the_document_are_refused_up_front(): + """A document whose Types include the reserved Globals name and the + empty name is refused with both offending names in one message, + before anything is loaded.""" + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + received = [] + pm.add_broadcast_sink(received.append) + + doc = { + "version": 2, + "parameters": {}, + "types": { + "_globals": {"parameters": {}, "nested": {}}, + "": {"parameters": {}, "nested": {}}, + }, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc) + + message = str(excinfo.value) + assert message.count("is not a valid Type name") == 2 + assert "the Globals submodule name is reserved" in message + assert "it is empty" in message + assert pm.list() == ["a"] + assert pm.list_types() == [] + assert received == [] + + +def test_target_validation_is_document_relative(): + """A Lock Target that exists in-session but is not a parameters key of + the document is refused: the validation reads the document, not the + live tree.""" + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + pm.add_parameter("b", initial_value=2, unit="v") + pm.lock("b", "a") + received = [] + pm.add_broadcast_sink(received.append) + + doc = { + "version": 2, + "parameters": { + "params.a": { + "value": 1, + "unit": "u", + "lock": {"target": "params.b", "locked": True}, + } + }, + "types": {}, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc, deleteMissing=False) + + message = str(excinfo.value) + assert "params.b" in message + assert "not a parameter of the document" in message + # the live Lock and every value are unchanged, nothing was emitted + assert pm.list() == ["a", "b"] + assert pm.get_lock("b") == PMLockBluePrint(target="params.a", locked=True) + assert pm.parameter("b").own_value() == 2 + assert received == [] + + +def test_nested_type_validation_is_document_relative(): + """A Nested Type that exists in-session but is missing from the + document's ``types`` is refused: the validation reads the document.""" + pm = ParameterManager(name="params") + pm.add_type("qubit") + pm.add_type("readout") + pm.add_nested_type("qubit", "readout", "readout") + + doc = { + "version": 2, + "parameters": {}, + "types": { + "qubit": {"parameters": {}, "nested": {"readout": "readout"}}, + }, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc, deleteMissing=False) + + assert "readout" in str(excinfo.value) + assert "not among the document's Types" in str(excinfo.value) + assert sorted(pm.list_types()) == ["qubit", "readout"] + assert pm.get_type("qubit").nested == {"readout": "readout"} + + +def test_nested_types_missing_from_the_document_are_refused_up_front(): + """A Nested Type that no Type of the document defines is refused + before anything is loaded, with every offender named.""" + pm = ParameterManager(name="params") + doc = { + "version": 2, + "parameters": {}, + "types": { + "qubit": {"parameters": {}, "nested": {"readout": "ghost_readout"}}, + "pulse": {"parameters": {}, "nested": {"win": "ghost_window"}}, + }, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc) + + message = str(excinfo.value) + assert "ghost_readout" in message + assert "ghost_window" in message + assert pm.list() == [] + assert pm.list_types() == [] + + +def test_a_nested_type_cycle_in_the_document_is_refused_up_front(): + pm = ParameterManager(name="params") + doc = { + "version": 2, + "parameters": {}, + "types": { + "qubit": {"parameters": {}, "nested": {"readout": "readout"}}, + "readout": {"parameters": {}, "nested": {"qubit": "qubit"}}, + }, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc) + + assert "qubit -> readout -> qubit" in str(excinfo.value) + assert pm.list_types() == [] + + +def test_a_duplicated_effective_path_in_the_document_is_refused_up_front(): + """A Type whose own entry collides with a Nested Type's entry in its + effective set is refused before anything is loaded.""" + pm = ParameterManager(name="params") + doc = { + "version": 2, + "parameters": {}, + "types": { + "readout": { + "parameters": { + "power": {"default": -10, "unit": "dBm", "target": None} + }, + "nested": {}, + }, + "qubit": { + "parameters": { + "readout.power": { + "default": None, + "unit": "dBm", + "target": None, + } + }, + "nested": {"readout": "readout"}, + }, + }, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc) + + assert "readout.power" in str(excinfo.value) + assert pm.list_types() == [] + + +def test_a_lock_cycle_in_the_document_is_refused_up_front(): + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + pm.add_parameter("b", initial_value=2, unit="v") + pm.lock("a", "b") # the in-session Lock the refused load must not disturb + doc = { + "version": 2, + "parameters": { + "params.a": { + "value": 1, + "unit": "u", + "lock": {"target": "params.b", "locked": True}, + }, + "params.b": { + "value": 2, + "unit": "v", + "lock": {"target": "params.a", "locked": True}, + }, + }, + "types": {}, + } + with pytest.raises(ValueError) as excinfo: + pm.fromParamDict(doc) + + message = str(excinfo.value) + assert "params.a" in message + assert "params.b" in message + assert "cycle in Lock targets" in message + assert pm.get_lock("a") == PMLockBluePrint(target="params.b", locked=True) + assert pm.get_lock("b") is None + + +def test_a_self_lock_in_the_document_is_refused_up_front(): + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + doc = { + "version": 2, + "parameters": { + "params.a": { + "value": 1, + "unit": "u", + "lock": {"target": "params.a", "locked": True}, + } + }, + "types": {}, + } + with pytest.raises(ValueError, match="cannot lock params.a to itself"): + pm.fromParamDict(doc) + + assert pm.get_lock("a") is None + + +def test_a_key_of_another_instrument_is_refused_up_front(): + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + doc = { + "version": 2, + "parameters": {"other.a": {"value": 1, "unit": "u"}}, + "types": {}, + } + with pytest.raises(ValueError, match="does not belong to this Parameter Manager"): + pm.fromParamDict(doc) + + assert pm.a() == 1 + + +def test_partial_instances_are_not_completed_and_get_no_type_lock(): + """The Types load with no Instance side effects (D20): a submodule + carrying one entry of a two-entry Type is not completed, is no + Instance, and gets no Lock from the entry's Type Lock.""" + pm = ParameterManager(name="params") + doc = { + "version": 2, + "parameters": { + "params._globals.qubit.IF": {"value": 1e9, "unit": "Hz"}, + "params.q01.IF": {"value": 2e9, "unit": "Hz"}, + }, + "types": { + "qubit": { + "parameters": { + "IF": { + "default": None, + "unit": "Hz", + "target": "params._globals.qubit.IF", + }, + "gain": {"default": 10, "unit": "dB", "target": None}, + }, + "nested": {}, + } + }, + } + pm.fromParamDict(doc) + + assert pm.list_types() == ["qubit"] + assert not pm.has_param("q01.gain") + assert pm.instances_of("qubit") == [] + # the Type Lock is stored on the entry but not applied on load + assert pm.get_lock("q01.IF") is None + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "params._globals.qubit.IF" + ) + + +def test_a_complete_instance_gets_no_type_lock_on_load(): + """Even a submodule the document completes into a full Instance of a + Type whose entry carries a Type Lock gets no Lock on load (D20): the + Types load with no Instance side effects, and an Instance's Locks come + only from the document's ``lock`` entries.""" + pm = ParameterManager(name="params") + doc = { + "version": 2, + "parameters": { + "params._globals.qubit.octave_gain": {"value": 10, "unit": "dB"}, + "params.q01.IF": {"value": 2e9, "unit": "Hz"}, + "params.q01.octave_gain": {"value": 12, "unit": "dB"}, + "params.q01.readout.power": {"value": -10, "unit": "dBm"}, + }, + "types": { + "readout": { + "parameters": { + "power": {"default": -10, "unit": "dBm", "target": None} + }, + "nested": {}, + }, + "qubit": { + "parameters": { + "IF": {"default": None, "unit": "Hz", "target": None}, + "octave_gain": { + "default": 10, + "unit": "dB", + "target": "params._globals.qubit.octave_gain", + }, + }, + "nested": {"readout": "readout"}, + }, + }, + } + pm.fromParamDict(doc) + + # q01 carries the whole effective set with the declared units: it is + # an Instance, computed by matching, not written by the load + assert pm.instances_of("qubit") == ["q01"] + # no ``lock`` entries in the document and no Type Lock applied on load + assert pm.get_lock("q01.octave_gain") is None + assert pm.get_lock("q01.IF") is None + assert pm.list_locks() == {} + # the load created exactly the document's parameters, nothing more + assert set(pm.list()) == { + "_globals.qubit.octave_gain", + "q01.IF", + "q01.octave_gain", + "q01.readout.power", + } + + +def test_a_listed_parameter_without_a_lock_entry_loses_its_lock(): + pm = ParameterManager(name="params") + pm.add_parameter("a", initial_value=1, unit="u") + pm.add_parameter("b", initial_value=2, unit="v") + pm.lock("a", "b") + doc = { + "version": 2, + "parameters": { + "params.a": {"value": 1, "unit": "u"}, + "params.b": {"value": 2, "unit": "v"}, + }, + "types": {}, + } + pm.fromParamDict(doc, deleteMissing=False) + + assert pm.get_lock("a") is None + assert pm.list_locks() == {} + + +def test_a_locked_follower_loads_its_own_value_and_the_files_lock_state(): + """Setting the stored own value on a currently locked Follower cannot + raise (D6): the listed Locks go first, and the Follower ends with the + file's Lock state.""" + pm = ParameterManager(name="params") + pm.add_parameter("q01.IF", initial_value=1.0, unit="Hz") + pm.add_parameter("q01Data.IF", initial_value=2.0, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + doc = { + "version": 2, + "parameters": { + "params.q01.IF": { + "value": 9.0, + "unit": "Hz", + "lock": {"target": "params.q01Data.IF", "locked": False}, + }, + "params.q01Data.IF": {"value": 2.0, "unit": "Hz"}, + }, + "types": {}, + } + pm.fromParamDict(doc) + + assert pm.parameter("q01.IF").own_value() == 9.0 + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="params.q01Data.IF", locked=False + ) + assert pm.get("q01.IF") == 9.0 + + +def make_deletion_manager(name="params"): + pm = ParameterManager(name=name) + pm.add_parameter("a", initial_value=1, unit="u") + pm.add_parameter("b", initial_value=2, unit="v") + pm.add_parameter("c", initial_value=3, unit="w") + pm.add_parameter("d", initial_value=4, unit="x") + pm.lock("c", "a") # c is not in the document: its Lock goes with it + pm.lock("a", "d") # re-created from the document with a new Target + pm.add_type("kept") + pm.add_type_parameter("kept", "k", default=1, unit="u") + pm.add_type("dropped") + pm.add_type_parameter("dropped", "x", default=2, unit="v") + return pm + + +DELETE_MISSING_DOC = { + "version": 2, + "parameters": { + "params.a": { + "value": 1, + "unit": "u", + "lock": {"target": "params.b", "locked": True}, + }, + "params.b": {"value": 2, "unit": "v"}, + }, + "types": { + "kept": { + "parameters": {"k": {"default": 1, "unit": "u", "target": None}}, + "nested": {}, + } + }, +} + + +def test_delete_missing_removes_absent_parameters_types_and_locks(): + pm = make_deletion_manager() + received = [] + pm.add_broadcast_sink(received.append) + pm.fromParamDict(DELETE_MISSING_DOC, deleteMissing=True) + + assert not pm.has_param("c") + assert not pm.has_param("d") + assert pm.list_types() == ["kept"] + assert pm.get_lock("a") == PMLockBluePrint(target="params.b", locked=True) + assert pm.list_locks() == {"a": PMLockBluePrint(target="params.b", locked=True)} + # the load's Broadcasts: one pm-type-update per Type written, one with + # None per Type removed, then one pm-lock-update per Lock that ended + # different (c's Lock ended removed with its parameter) + assert [(bp.action, bp.name) for bp in received] == [ + (PM_TYPE_UPDATE, "params.kept"), + (PM_TYPE_UPDATE, "params.dropped"), + (PM_LOCK_UPDATE, "params.a"), + (PM_LOCK_UPDATE, "params.c"), + ] + assert received[0].value == pm.get_type("kept") + assert received[1].value is None + assert received[2].value == PMLockBluePrint(target="params.b", locked=True) + assert received[3].value is None + + +def test_delete_missing_false_keeps_absent_parameters_types_and_locks(): + pm = make_deletion_manager() + received = [] + pm.add_broadcast_sink(received.append) + pm.fromParamDict(DELETE_MISSING_DOC, deleteMissing=False) + + assert pm.has_param("c") + assert pm.has_param("d") + assert sorted(pm.list_types()) == ["dropped", "kept"] + # c keeps its Lock; a's Lock is re-created from the document + assert pm.get_lock("c") == PMLockBluePrint(target="params.a", locked=True) + assert pm.get_lock("a") == PMLockBluePrint(target="params.b", locked=True) + # nothing was removed, so no None payload went out; c's unchanged Lock + # emits nothing + assert [(bp.action, bp.name) for bp in received] == [ + (PM_TYPE_UPDATE, "params.kept"), + (PM_LOCK_UPDATE, "params.a"), + ] + + +def test_one_load_emits_type_updates_then_lock_updates_and_nothing_for_values(): + """The Broadcasts of one load, in order: the pm-type-updates of the + Types written, then the pm-lock-updates of the Locks that ended + different, in tree order — and nothing for the values, the created + parameters or a refused load.""" + pm = ParameterManager(name="params") + received = [] + pm.add_broadcast_sink(received.append) + + doc = { + "version": 2, + "parameters": { + "params._globals.qubit.octave_gain": {"value": 10, "unit": "dB"}, + "params.q01.octave_gain": { + "value": 12, + "unit": "dB", + "lock": { + "target": "params._globals.qubit.octave_gain", + "locked": True, + }, + }, + "params.q02.octave_gain": { + "value": 13, + "unit": "dB", + "lock": { + "target": "params._globals.qubit.octave_gain", + "locked": False, + }, + }, + }, + "types": { + "qubit": { + "parameters": { + "octave_gain": { + "default": 10, + "unit": "dB", + "target": "params._globals.qubit.octave_gain", + } + }, + "nested": {}, + } + }, + } + pm.fromParamDict(doc) + + assert [(bp.action, bp.name) for bp in received] == [ + (PM_TYPE_UPDATE, "params.qubit"), + (PM_LOCK_UPDATE, "params.q01.octave_gain"), + (PM_LOCK_UPDATE, "params.q02.octave_gain"), + ] + assert received[0].value.name == "qubit" + assert received[1].value == PMLockBluePrint( + target="params._globals.qubit.octave_gain", locked=True + ) + assert received[2].value == PMLockBluePrint( + target="params._globals.qubit.octave_gain", locked=False + ) + # the values and the created parameters emit nothing from the + # Parameter Manager (D22 keeps the reader's behaviour) + assert not any( + bp.action in (PARAMETER_CREATION, PARAMETER_DELETION) for bp in received + ) + + # a refused load emits nothing + received.clear() + refused = { + "version": 2, + "parameters": { + "params.a": { + "value": 1, + "unit": "u", + "lock": {"target": "params.ghost", "locked": True}, + } + }, + "types": {}, + } + with pytest.raises(ValueError): + pm.fromParamDict(refused) + assert received == [] + + +# --------------------------------------------------------------------------- +# Profiles (4.3): switch_to_profile = save → clear → load (D20) +# --------------------------------------------------------------------------- + + +EMPTY_DOC = { + "version": 2, + "parameters": {"params.spare": {"value": 1, "unit": "V"}}, + "types": {}, +} + + +def make_profile_switch_manager(tmp_path, monkeypatch, name="params"): + """The first profile of the switching tests: a Parameter Manager + holding Types (one nesting another), an Instance of the outer Type, a + Type Lock with its default Globals Target (given its own value), an + explicit-Target Lock and an unlocked Lock — saved as profile + ``typed`` beside the profile ``empty``, which holds one parameter and + no Types and no Locks. Both files are listed as profiles.""" + monkeypatch.chdir(tmp_path) + pm = ParameterManager(name=name) + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_type("readout") + pm.add_type_parameter("readout", "power", default=-10, unit="dBm") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_instance("qubit", "q01") + # the Type Lock with the default Globals Target: it creates the + # Globals parameter on demand and locks the Instance's parameter + pm.lock_type_parameter("qubit", "octave_gain") + pm.set("_globals.qubit.octave_gain", 33) + # an ordinary Lock with an explicit Target, and an unlocked Lock + pm.add_parameter("q01Data.IF", initial_value=42e6, unit="Hz") + pm.lock("q01.IF", "q01Data.IF") + pm.add_parameter("monitor.IF", initial_value=0, unit="Hz") + pm.lock("monitor.IF", "q01Data.IF") + pm.unlock("monitor.IF") + pm.toFile(tmp_path, "typed") + (tmp_path / "parameter_manager-empty.json").write_text(json.dumps(EMPTY_DOC)) + pm.refresh_profiles() + return pm + + +def test_switching_to_a_profile_without_types_or_locks_clears_them( + tmp_path, monkeypatch +): + """Switching from a profile holding Types, a Type Lock with its + Globals Target, Locks in both states and a Globals parameter to a + profile without any leaves no Type, no Lock and no Globals submodule + behind: only the new profile's parameters are there (D20).""" + pm = make_profile_switch_manager(tmp_path, monkeypatch) + + pm.switch_to_profile("empty") + + assert pm.list_types() == [] + assert pm.list_locks() == {} + assert "_globals" not in pm.submodules + assert pm.list() == ["spare"] + assert pm.get("spare") == 1 + assert pm.parameter("spare").unit == "V" + assert pm.selectedProfile == "parameter_manager-empty.json" + + +def test_switching_back_restores_types_locks_and_the_globals_parameter( + tmp_path, monkeypatch +): + """Switching to a profile without Types and back restores every Type + (``get_type`` equal), every Lock in its stored state, and the Globals + Target parameter with its own value: the round trip through the + profiles.""" + pm = make_profile_switch_manager(tmp_path, monkeypatch) + before_types = {t: pm.get_type(t) for t in pm.list_types()} + before_locks = pm.list_locks() + + pm.switch_to_profile("empty") + assert pm.list_types() == [] + assert pm.list_locks() == {} + + pm.switch_to_profile("typed") + + assert sorted(pm.list_types()) == ["qubit", "readout"] + for type_name, blueprint in before_types.items(): + assert pm.get_type(type_name) == blueprint + assert pm.list_locks() == before_locks + # the Globals Target parameter comes back with its own value ... + assert pm.get("_globals.qubit.octave_gain") == 33 + assert pm.parameter("_globals.qubit.octave_gain").unit == "dB" + # ... and the restored Locks pull again: q01.octave_gain is locked to + # the Globals Target, q01.IF to its explicit Target, and monitor.IF + # stays unlocked and answers with its own value (D5) + assert pm.get("q01.octave_gain") == 33 + assert pm.parameter("q01.octave_gain").own_value() == 10 + assert pm.get("q01.IF") == 42e6 + assert pm.get("monitor.IF") == 0 + assert pm.instances_of("qubit") == ["q01"] + assert set(pm.list()) == { + "q01.IF", + "q01.octave_gain", + "q01.readout.power", + "monitor.IF", + "q01Data.IF", + "_globals.qubit.octave_gain", + } + assert pm.selectedProfile == "parameter_manager-typed.json" + + +def test_the_switch_saves_the_leaving_profile_as_a_version_two_document( + tmp_path, monkeypatch +): + """The save step of a switch writes the leaving profile as the + version-2 document (D19): the Types with the full-form Type Lock + Target, the Followers' ``lock`` entries in both states and the + Globals parameter with its own value. A change made after the + helper's own save can only have been recorded by the switch's + save.""" + pm = make_profile_switch_manager(tmp_path, monkeypatch) + pm.set("_globals.qubit.octave_gain", 34) + pm.refresh_profiles() + + pm.switch_to_profile("empty") + + with open(tmp_path / "parameter_manager-typed.json") as f: + doc = json.load(f) + assert doc["version"] == 2 + assert set(doc["types"]) == {"qubit", "readout"} + assert doc["types"]["qubit"]["nested"] == {"readout": "readout"} + assert doc["types"]["qubit"]["parameters"]["octave_gain"] == { + "default": 10, + "unit": "dB", + "target": "params._globals.qubit.octave_gain", + } + assert doc["types"]["qubit"]["parameters"]["IF"] == { + "default": None, + "unit": "Hz", + "target": None, + } + assert doc["parameters"]["params._globals.qubit.octave_gain"] == { + "value": 34, + "unit": "dB", + } + assert doc["parameters"]["params.q01.octave_gain"]["lock"] == { + "target": "params._globals.qubit.octave_gain", + "locked": True, + } + assert doc["parameters"]["params.q01.IF"]["lock"] == { + "target": "params.q01Data.IF", + "locked": True, + } + assert doc["parameters"]["params.monitor.IF"]["lock"] == { + "target": "params.q01Data.IF", + "locked": False, + } + + +def test_switching_to_a_legacy_flat_profile_leaves_no_type_or_lock( + tmp_path, monkeypatch +): + """A legacy flat profile file (no ``version`` key) loads as + parameters only; switching to it works, and no Type and no Lock of + the previous profile is left — the clear step ran before the load.""" + pm = make_profile_switch_manager(tmp_path, monkeypatch) + legacy = { + "params.legacy_a": {"value": 5, "unit": "V"}, + "params.legacy_b.child": {"value": 6, "unit": "A"}, + } + (tmp_path / "parameter_manager-legacy_flat.json").write_text(json.dumps(legacy)) + pm.refresh_profiles() + + pm.switch_to_profile("legacy_flat") + + assert pm.list_types() == [] + assert pm.list_locks() == {} + assert set(pm.list()) == {"legacy_a", "legacy_b.child"} + assert pm.get("legacy_a") == 5 + assert pm.parameter("legacy_a").unit == "V" + assert pm.get("legacy_b.child") == 6 + assert "_globals" not in pm.submodules + assert pm.selectedProfile == "parameter_manager-legacy_flat.json" + + +def test_clear_all_emits_type_updates_then_lock_updates_and_nothing_else( + tmp_path, monkeypatch +): + """``_clear_all`` emits one ``pm-type-update`` with a ``None`` payload + per removed Type, in registry order, then whatever + ``remove_all_parameters`` emits — the ``pm-lock-update`` ``None`` of a + Lock dropped with its Target — and nothing else. No Type Lock clearing + is reported, because the Types are already out of the registry when + the parameters go.""" + monkeypatch.chdir(tmp_path) + pm = ParameterManager(name="params") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type("readout") + pm.add_type_parameter("readout", "power", default=-10, unit="dBm") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_instance("qubit", "q01") + pm.lock_type_parameter("qubit", "IF") # Globals Target _globals.qubit.IF + # a Lock whose Follower is removed after its Target in tree order, so + # its removal is reported with a pm-lock-update + pm.add_parameter("q01Data.target", initial_value=1, unit="V") + pm.add_parameter("follower", initial_value=0, unit="V") + pm.lock("follower", "q01Data.target") + + received = [] + pm.add_broadcast_sink(received.append) + received.clear() + + pm._clear_all() + + assert [(bp.action, bp.name) for bp in received] == [ + (PM_TYPE_UPDATE, "params.qubit"), + (PM_TYPE_UPDATE, "params.readout"), + (PM_LOCK_UPDATE, "params.follower"), + ] + assert all(bp.value is None for bp in received) + # everything is gone, the Globals submodule included + assert pm.list_types() == [] + assert pm.list_locks() == {} + assert pm.list() == [] + assert "_globals" not in pm.submodules + + +def test_switch_to_an_unknown_profile_raises_and_saves_nothing(tmp_path, monkeypatch): + """An unknown profile raises ``ValueError`` before anything happens: + the current profile file keeps its contents and modification time, + the state of the Parameter Manager is untouched, and no file is + created.""" + pm = make_profile_switch_manager(tmp_path, monkeypatch) + pm.refresh_profiles() + typed_path = tmp_path / "parameter_manager-typed.json" + before_mtime = typed_path.stat().st_mtime_ns + before_text = typed_path.read_text() + before_state = (pm.list(), pm.list_locks(), pm.list_types()) + + with pytest.raises(ValueError, match="Profile no_such does not exist"): + pm.switch_to_profile("no_such") + + assert typed_path.stat().st_mtime_ns == before_mtime + assert typed_path.read_text() == before_text + assert (pm.list(), pm.list_locks(), pm.list_types()) == before_state + assert pm.selectedProfile == "parameter_manager-typed.json" + assert sorted(path.name for path in tmp_path.iterdir()) == [ + "parameter_manager-empty.json", + "parameter_manager-typed.json", + ] + + +def test_refresh_and_list_profiles_are_the_same_around_a_switch(tmp_path, monkeypatch): + """``refresh_profiles``/``list_profiles`` are unchanged by a switch: + they list the same profile files before and after — plus the current + profile's file when the switch's save creates it.""" + # both profile files exist before the switch + pm = make_profile_switch_manager(tmp_path, monkeypatch) + before = sorted(pm.refresh_profiles()) + assert sorted(pm.list_profiles()) == before + + pm.switch_to_profile("empty") + + assert sorted(pm.refresh_profiles()) == before + assert sorted(pm.list_profiles()) == before + + # the current profile's file does not exist yet: the switch's save + # creates it, and the refreshed list grows by exactly that file + (tmp_path / "parameter_manager-empty2.json").write_text( + json.dumps( + { + "version": 2, + "parameters": {"params2.spare2": {"value": 2, "unit": "W"}}, + "types": {}, + } + ) + ) + pm2 = ParameterManager(name="params2") + pm2.add_parameter("x", initial_value=1, unit="u") + before2 = sorted(pm2.refresh_profiles()) + assert "parameter_manager-params2.json" not in before2 + + pm2.switch_to_profile("empty2") + + after2 = sorted(pm2.refresh_profiles()) + assert after2 == sorted(before2 + ["parameter_manager-params2.json"]) + assert sorted(pm2.list_profiles()) == after2 + assert pm2.get("spare2") == 2 diff --git a/test/pytest/test_pm_types.py b/test/pytest/test_pm_types.py new file mode 100644 index 0000000..1f286f7 --- /dev/null +++ b/test/pytest/test_pm_types.py @@ -0,0 +1,4193 @@ +"""Tests for the Type registry and definitions (plan task 2.1), the +duck-typed Instance matching (plan task 2.2), the Type edits with +Instance side effects (plan task 2.3), ``add_instance`` (plan task 2.4), +the ``pm-type-update`` and side-effect creation Broadcasts (plan task +2.5), the Globals rules with the ``_ensure_global_target`` helper (plan +task 3.1) and the Type Locks ``lock_type_parameter`` / +``unlock_type_parameter`` with their application to new Instances (plan +task 3.2). + +The definition and editing methods are exercised through the public API: +``add_type`` / ``add_type_parameter`` / ``add_nested_type`` and friends. +The states the public API refuses to build — Nested Type cycles, Nested +Types missing from the registry, effective sets with a duplicated path — +are still inserted into the registry by hand with the ``put_type`` +helper. +Covered: creating, listing, getting and removing Types (with the +reserved Globals name and duplicate-name refusals), the effective +parameter set expansion of Nested Types (cycle refusal, collision +refusal), the content of the ``PMTypeBluePrint`` ``get_type`` returns, +its round-trip through the blueprint serialization, the Instance matching +queries ``instances_of`` and ``types_of`` (existence and unit, any depth, +never the root, never the Globals submodule, ordering of the claiming +Types), the six editing methods with their D13 Instance side effects, and +``add_instance`` (creating the missing effective entries with defaults +and units, keeping existing ones with value and unit, dotted names, the +three-tier Type of the mock, the up-front unit-conflict scan, blocked +targets, the Globals refusal, unknown and empty Types) — every refusal +leaving the registry and the parameter tree byte-identical. +The Broadcast part checks, on a local Parameter Manager with a sink, that +every Type-editing method emits ``pm-type-update`` (one per affected +Type, the edited one first; ``None`` on ``remove_type``) after the +mutation, that the parameters created as side effects are re-emitted as +``parameter-creation`` in creation order before the Type updates, and +that read-only queries and refused calls emit nothing. The last part +exercises the Type API through a client proxy against a live Server: +every method callable over the wire, ``get_type``/``list_types`` +deserialising, and a SubClient receiving ``pm-type-update`` and the +per-parameter ``parameter-creation`` Broadcasts of an ``add_instance`` +issued from a second client, whose creations the first client's proxy +shows after ``update()``. +The Globals part checks that ``add_parameter`` refuses the reserved +Globals name (naming the offending path and creating nothing, through +the root and through a Parameter Group's routed call), that +``add_instance`` under Globals still raises, that Globals parameters +created by the internal ``_ensure_global_target`` helper stay excluded +from ``instances_of``/``types_of`` even when they carry a full Instance +shape, and the helper itself: creating ``_globals..`` with +the entry's default and unit as a ``ManagedParameter``, returning the +dotted path, emitting exactly one ``parameter-creation`` and nothing on +a second (idempotent) call, refusing an unknown Type, a path that is not +an own entry, a unit conflict on the existing parameter, a parameter on +the way and a Parameter Group at the target — each leaving the tree +byte-identical — and that a Globals parameter is otherwise ordinary (set, +read, Target of a Lock). The proxy part exercises the ``add_parameter`` +refusal over the wire. +The Type Lock part (3.2) checks that ``lock_type_parameter`` locks every +current Instance's parameter to the Target — the default Globals one, +created on demand with the entry's default and unit, or an explicit +ordinary parameter — that the skipped Followers carrying a Lock on +another Target are returned and named in one warning, that re-declaring +re-applies ("lock all") and a different Target skips the old Followers, +that ``unlock_type_parameter`` removes only the rule while every Lock +stays, that an Instance falling out keeps its Locks, that +``add_instance`` locks the new Instance's parameters at the locked +entries (a kept parameter included, with its own value and unit +untouched) and skips the ones it cannot lock with one warning, that +``add_nested_type`` applies the Nested Type's Type Lock under the +submodule (a pre-existing parameter included) while ``add_type_parameter`` +applies none (its fresh entries carry no Target), that a hand-inserted +registry whose stored Target points nowhere makes the application skip +with one warning, and the refusals +(unknown Type, non-own entry, missing explicit Target, self-lock, cycle, +parameters that cannot carry a Lock) each leaving the tree, the Locks and +the registry byte-identical. The Broadcast part checks the order +parameter-creation, pm-lock-updates, pm-type-update for a declaration, +the single pm-type-update of a removal and of an all-locked re-declare, +and that refused calls emit nothing. The proxy part exercises both +methods over the wire (the skipped list round-tripping, the full-form +Target in ``get_type``, a locked Instance parameter pulling the Globals +value) and a SubClient receiving the declaration Broadcasts made by a +second client. +The deletion interplay part (3.3) checks that removing a parameter that is +the stored Target of Type Locks — the default Globals one or an explicit +ordinary one, carried by one Type or shared by two — drops every Lock +pointing at it (the Followers answer ``get`` with their own values again) +and clears the Type Locks (``get_type`` shows ``target`` ``None``), that +removing a Target of ordinary Locks touches no Type Lock, that removing a +Follower touches none either, that a refused removal leaves Locks and +Types untouched, that ``remove_type`` leaves the Globals parameters and +every Instance Lock alone while ``remove_type_parameter`` takes the +entry's Type Lock with it, and that ``remove_all_parameters`` clears the +Type Locks whose Targets it removes while the Type definitions stay. The +Broadcast part pins the order: one ``pm-lock-update`` with ``None`` per +dropped Follower, then one ``pm-type-update`` per affected Type in +registry order — one per Type even when two of its entries shared the +removed Target — and nothing else; ``remove_type`` keeps its single +``None`` update with no ``pm-lock-update``; a refused removal and a +Follower removal emit nothing. The proxy part removes a Globals Type Lock +Target through a second client: the SubClient sees the ``pm-lock-update`` +(``None``) per Follower, the ``pm-type-update`` with the cleared Target +and the Server's ``parameter-deletion``, the first client's proxy shows +the Follower unlocked after ``update()``, and ``get_type`` over the wire +shows ``target`` ``None``. +""" + +import copy +import logging +import re + +import pytest + +from instrumentserver.blueprints import ( + PARAMETER_CREATION, + PARAMETER_DELETION, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ParameterBroadcastBluePrint, + PMLockBluePrint, + PMTypeBluePrint, + deserialize_obj, +) +from instrumentserver.client.proxy import Client +from instrumentserver.params import ( + ManagedParameter, + ParameterManager, + _TypeDefinition, + _TypeEntry, +) + + +@pytest.fixture +def pm(tmp_path, monkeypatch): + """A fresh Parameter Manager in an empty working directory.""" + monkeypatch.chdir(tmp_path) + return ParameterManager(name="parameter_manager") + + +def put_type(pm, name, parameters=None, nested=None): + """Insert a ``_TypeDefinition`` into the registry directly: for the + states the public editing API refuses to build (Nested Type cycles, + missing Nested Types, effective sets with a duplicated path).""" + pm._types[name] = _TypeDefinition( + name=name, + parameters={ + path: _TypeEntry(**entry) for path, entry in (parameters or {}).items() + }, + nested=dict(nested or {}), + ) + + +def put_three_tier_registry(pm): + """The mock's three-tier case: ``qubit`` nests ``readout`` at its + submodule ``readout``, which nests ``pulse_window`` at its submodule + ``pw``.""" + pm.add_type("pulse_window") + pm.add_type_parameter("pulse_window", "duration", default=None, unit="s") + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type_parameter("readout", "window", default=None, unit="s") + pm.add_nested_type("readout", "pw", "pulse_window") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_nested_type("qubit", "readout", "readout") + + +# --------------------------------------------------------------------------- +# Definitions: add_type, list_types, remove_type +# --------------------------------------------------------------------------- + + +def test_add_type_creates_an_empty_type(pm): + pm.add_type("qubit") + + assert pm.list_types() == ["qubit"] + bp = pm.get_type("qubit") + assert bp.name == "qubit" + assert bp.parameters == {} + assert bp.nested == {} + # an empty Type has no effective paths and no Instances + assert bp.effective == {} + + +def test_add_type_refuses_the_reserved_globals_name(pm): + with pytest.raises(ValueError, match="'_globals' is not a valid Type name"): + pm.add_type("_globals") + + assert pm.list_types() == [] + + +def test_the_type_registry_lives_on_the_root_only(pm): + pm.add_parameter("q01.IF") + + assert hasattr(pm, "_types") + # the Parameter Group q01 carries no registry and no Type methods (D15) + assert not hasattr(pm.q01, "_types") + assert not hasattr(pm.q01, "add_type") + + +def test_add_type_refuses_a_duplicate_name_and_changes_nothing(pm): + pm.add_type("qubit") + + with pytest.raises(ValueError, match="a Type named 'qubit' already exists"): + pm.add_type("qubit") + + # the refused call left the original Type untouched + assert pm.list_types() == ["qubit"] + assert pm.get_type("qubit").parameters == {} + + +def test_list_types_lists_every_type(pm): + assert pm.list_types() == [] + + pm.add_type("qubit") + pm.add_type("readout") + + assert sorted(pm.list_types()) == ["qubit", "readout"] + + +def test_remove_type_removes_the_type(pm): + pm.add_type("qubit") + pm.add_type("readout") + + pm.remove_type("qubit") + + assert pm.list_types() == ["readout"] + with pytest.raises(ValueError, match="no Type named 'qubit' exists"): + pm.get_type("qubit") + + +def test_remove_type_with_an_unknown_name_raises_naming_it(pm): + with pytest.raises(ValueError, match="no Type named 'qubit' exists"): + pm.remove_type("qubit") + + assert pm.list_types() == [] + + +def test_remove_type_refused_while_nested_in_another_type(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape("cannot remove Type 'readout': nested in Type(s) 'qubit'"), + ): + pm.remove_type("readout") + + # the refused removal left both Types in the registry, untouched + assert sorted(pm.list_types()) == ["qubit", "readout"] + assert pm.get_type("readout").parameters == { + "IF": {"default": None, "unit": "Hz", "target": None} + } + + +def test_remove_type_names_every_type_nesting_it(pm): + pm.add_type("readout") + pm.add_type("qubit") + pm.add_type("qubit2") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_nested_type("qubit2", "readout", "readout") + + with pytest.raises(ValueError) as excinfo: + pm.remove_type("readout") + + # every offending Type, not the first (rule 3) + assert str(excinfo.value) == ( + "cannot remove Type 'readout': nested in Type(s) 'qubit', 'qubit2'" + ) + assert sorted(pm.list_types()) == ["qubit", "qubit2", "readout"] + + +def test_remove_type_removes_a_type_that_nests_other_types(pm): + # qubit nests readout but is nested by nobody: removing it is allowed, + # and the Nested Type readout stays in the registry untouched + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + pm.remove_type("qubit") + + assert pm.list_types() == ["readout"] + assert pm.get_type("readout").parameters == { + "IF": {"default": None, "unit": "Hz", "target": None} + } + + +def test_get_type_with_an_unknown_name_raises_naming_it(pm): + with pytest.raises(ValueError, match="no Type named 'qubit' exists"): + pm.get_type("qubit") + + +# --------------------------------------------------------------------------- +# Effective set: _effective_parameters +# --------------------------------------------------------------------------- + + +def test_effective_set_expands_nested_types_recursively(pm): + put_three_tier_registry(pm) + + assert pm._effective_parameters("qubit") == { + "IF": {"unit": "Hz", "from_type": "qubit"}, + "octave_gain": {"unit": "dB", "from_type": "qubit"}, + "readout.IF": {"unit": "Hz", "from_type": "readout"}, + "readout.window": {"unit": "s", "from_type": "readout"}, + "readout.pw.duration": {"unit": "s", "from_type": "pulse_window"}, + } + + +def test_effective_set_of_the_middle_tier(pm): + put_three_tier_registry(pm) + + assert pm._effective_parameters("readout") == { + "IF": {"unit": "Hz", "from_type": "readout"}, + "window": {"unit": "s", "from_type": "readout"}, + "pw.duration": {"unit": "s", "from_type": "pulse_window"}, + } + + +def test_the_same_type_nested_twice_is_not_a_cycle(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "ro1", "readout") + pm.add_nested_type("qubit", "ro2", "readout") + + assert pm._effective_parameters("qubit") == { + "ro1.IF": {"unit": "Hz", "from_type": "readout"}, + "ro2.IF": {"unit": "Hz", "from_type": "readout"}, + } + + +def test_effective_set_refuses_a_cycle(pm): + put_type(pm, "qubit", nested={"readout": "readout"}) + put_type(pm, "readout", nested={"qubit": "qubit"}) + + with pytest.raises( + ValueError, + match=re.escape("cycle in nested Types: qubit -> readout -> qubit"), + ): + pm._effective_parameters("qubit") + + # get_type expands the effective set too, so it refuses the cycle as + # well, walking from its own Type + with pytest.raises( + ValueError, + match=re.escape("cycle in nested Types: readout -> qubit -> readout"), + ): + pm.get_type("readout") + + +def test_effective_set_refuses_a_type_nested_in_itself(pm): + put_type(pm, "loop", nested={"self": "loop"}) + + with pytest.raises( + ValueError, match=re.escape("cycle in nested Types: loop -> loop") + ): + pm._effective_parameters("loop") + + +def test_effective_set_refuses_a_nested_type_missing_from_the_registry(pm): + put_type(pm, "qubit", nested={"readout": "readout"}) + + with pytest.raises( + ValueError, + match=re.escape("Type 'qubit' nests 'readout', which does not exist"), + ): + pm._effective_parameters("qubit") + + +def test_effective_set_refuses_a_duplicated_path_and_names_it(pm): + # the Type's own entry "readout.IF" collides with the entry "IF" of + # the Nested Type required at the submodule "readout" + put_type( + pm, + "qubit", + parameters={"readout.IF": {"default": None, "unit": "Hz"}}, + nested={"readout": "readout"}, + ) + put_type(pm, "readout", parameters={"IF": {"default": None, "unit": "Hz"}}) + + with pytest.raises( + ValueError, + match=re.escape( + "parameter path(s) 'readout.IF' appear more than once in the " + "effective set of Type 'qubit'" + ), + ): + pm._effective_parameters("qubit") + + +def test_effective_set_names_every_duplicated_path(pm): + put_type( + pm, + "qubit", + parameters={ + "readout.IF": {"default": None, "unit": "Hz"}, + "readout.window": {"default": None, "unit": "s"}, + }, + nested={"readout": "readout"}, + ) + put_type( + pm, + "readout", + parameters={ + "IF": {"default": None, "unit": "Hz"}, + "window": {"default": None, "unit": "s"}, + }, + ) + + with pytest.raises(ValueError) as excinfo: + pm._effective_parameters("qubit") + + # every offending path, not the first (rule 3) + message = str(excinfo.value) + assert ( + "parameter path(s) 'readout.IF', 'readout.window' appear more than once" + in message + ) + assert message.endswith("effective set of Type 'qubit'") + + +# --------------------------------------------------------------------------- +# Blueprint content: get_type and PMTypeBluePrint +# --------------------------------------------------------------------------- + + +def test_get_type_returns_the_full_blueprint(pm): + put_three_tier_registry(pm) + + bp = pm.get_type("qubit") + + assert isinstance(bp, PMTypeBluePrint) + assert bp.name == "qubit" + assert bp.parameters == { + "IF": {"default": None, "unit": "Hz", "target": None}, + "octave_gain": {"default": 10, "unit": "dB", "target": None}, + } + assert bp.nested == {"readout": "readout"} + assert bp.effective == { + "IF": {"unit": "Hz", "from_type": "qubit"}, + "octave_gain": {"unit": "dB", "from_type": "qubit"}, + "readout.IF": {"unit": "Hz", "from_type": "readout"}, + "readout.window": {"unit": "s", "from_type": "readout"}, + "readout.pw.duration": {"unit": "s", "from_type": "pulse_window"}, + } + + +def test_pm_type_blueprint_round_trips_through_serialization(): + bp = PMTypeBluePrint( + name="qubit", + parameters={ + "IF": {"default": None, "unit": "Hz", "target": None}, + "octave_gain": {"default": 10, "unit": "dB", "target": None}, + }, + nested={"readout": "readout"}, + effective={ + "IF": {"unit": "Hz", "from_type": "qubit"}, + "octave_gain": {"unit": "dB", "from_type": "qubit"}, + }, + ) + + round_tripped = deserialize_obj(bp.toJson()) + + assert isinstance(round_tripped, PMTypeBluePrint) + assert round_tripped == bp + + +# --------------------------------------------------------------------------- +# Instance matching: instances_of and types_of (plan task 2.2) +# --------------------------------------------------------------------------- + + +def put_three_tier_tree(pm): + """The parameter tree matching the three-tier registry: ``q01`` is an + Instance of ``qubit``, ``q01.readout`` of ``readout``, and + ``q01.readout.pw`` of ``pulse_window``. Every parameter carries a + value of its own, which matching must ignore.""" + pm.add_parameter("q01.IF", initial_value=5e9, unit="Hz") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + pm.add_parameter("q01.readout.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01.readout.window", initial_value=2e-6, unit="s") + pm.add_parameter("q01.readout.pw.duration", initial_value=500e-9, unit="s") + + +def put_globals_parameter(pm, path, unit=""): + """Create a parameter under the reserved Globals submodule the way the + internal default-Target helper will (the public ``add_parameter`` + refuses Globals from plan task 3.1 on).""" + parent = pm._get_parent(path, create_parent=True) + parent._add_own_parameter(path.split(".")[-1], unit=unit) + + +def test_instances_of_finds_the_three_tier_instances(pm): + put_three_tier_registry(pm) + put_three_tier_tree(pm) + + assert pm.instances_of("qubit") == ["q01"] + assert pm.instances_of("readout") == ["q01.readout"] + assert pm.instances_of("pulse_window") == ["q01.readout.pw"] + + +def test_types_of_orders_innermost_first(pm): + put_three_tier_registry(pm) + put_three_tier_tree(pm) + + # the deepest Instance claims first: pulse_window (1 effective path) + # before readout (3) before qubit (5) + assert pm.types_of("q01.readout.pw.duration") == [ + "pulse_window", + "readout", + "qubit", + ] + assert pm.types_of("q01.readout.IF") == ["readout", "qubit"] + assert pm.types_of("q01.IF") == ["qubit"] + assert pm.types_of("q01.octave_gain") == ["qubit"] + + +def test_a_type_claiming_through_two_instances_is_ordered_by_its_innermost_instance(pm): + # one Type claiming a parameter through more than one Instance is + # listed once, ordered by its innermost Instance + pm.add_type("zzz") + pm.add_type_parameter("zzz", "x", default=None, unit="Hz") + pm.add_type_parameter("zzz", "a.x", default=None, unit="Hz") + pm.add_type("aaa") + pm.add_type_parameter("aaa", "a.x", default=None, unit="Hz") + pm.add_type_parameter("aaa", "top", default=None, unit="s") + pm.add_parameter("a.x", unit="Hz") + pm.add_parameter("a.top", unit="s") + pm.add_parameter("a.a.x", unit="Hz") + pm.add_parameter("a.a.a.x", unit="Hz") + + # both a and a.a carry the whole zzz shape (x and a.x with unit Hz) + assert pm.instances_of("zzz") == ["a", "a.a"] + # aaa is carried by a alone (a.a lacks a.top) + assert pm.instances_of("aaa") == ["a"] + # zzz claims a.a.x through a.a (innermost) and through a; aaa claims + # it only through a: the innermost Instance puts zzz first, although + # the Type-name tie-break alone would put aaa first + assert pm.types_of("a.a.x") == ["zzz", "aaa"] + + +def test_q01_readout_is_an_instance_of_readout_on_its_own(pm): + # the parent carries only the readout shape, not the full qubit shape: + # the nested Parameter Group matches readout regardless of its parent + put_three_tier_registry(pm) + pm.add_parameter("q02.readout.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q02.readout.window", initial_value=2e-6, unit="s") + pm.add_parameter("q02.readout.pw.duration", initial_value=500e-9, unit="s") + + assert pm.instances_of("readout") == ["q02.readout"] + assert pm.instances_of("qubit") == [] + assert pm.types_of("q02.readout.IF") == ["readout"] + + +def test_a_unit_mismatch_excludes_the_submodule(pm): + # every effective path exists, but octave_gain carries the wrong unit: + # a match requires existence and unit (D12), so the whole submodule is + # no Instance + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.IF", unit="Hz") + pm.add_parameter("q01.octave_gain", unit="Hz") + + assert pm.instances_of("qubit") == [] + assert pm.types_of("q01.IF") == [] + + +def test_a_unit_mismatch_at_one_level_leaves_the_deeper_instances(pm): + # q03.readout.window carries the wrong unit: neither q03 (for qubit) + # nor q03.readout (for readout) matches, but q03.readout.pw still + # matches pulse_window on its own + put_three_tier_registry(pm) + pm.add_parameter("q03.IF", unit="Hz") + pm.add_parameter("q03.octave_gain", unit="dB") + pm.add_parameter("q03.readout.IF", unit="Hz") + pm.add_parameter("q03.readout.window", unit="Hz") + pm.add_parameter("q03.readout.pw.duration", unit="s") + + assert pm.instances_of("qubit") == [] + assert pm.instances_of("readout") == [] + assert pm.instances_of("pulse_window") == ["q03.readout.pw"] + assert pm.types_of("q03.readout.pw.duration") == ["pulse_window"] + + +def test_extra_parameters_do_not_matter(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_parameter("q01.IF", unit="Hz") + # extra parameters and extra Parameter Groups change nothing + pm.add_parameter("q01.extra", unit="dB") + pm.add_parameter("q01.sub.extra", unit="s") + + assert pm.instances_of("qubit") == ["q01"] + assert pm.types_of("q01.IF") == ["qubit"] + + +def test_two_types_on_one_submodule(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type_parameter("readout", "window", default=None, unit="s") + pm.add_type("ro_small") + pm.add_type_parameter("ro_small", "IF", default=None, unit="Hz") + pm.add_parameter("q01.readout.IF", unit="Hz") + pm.add_parameter("q01.readout.window", unit="s") + + # one submodule can be an Instance of several Types at once + assert pm.instances_of("readout") == ["q01.readout"] + assert pm.instances_of("ro_small") == ["q01.readout"] + # the same Instance claims both, so the larger effective set wins (D16) + assert pm.types_of("q01.readout.IF") == ["readout", "ro_small"] + + +def test_types_of_breaks_a_tie_by_type_name(pm): + # two Types of the same size on the same Instance: the order falls + # back to the Type name (the registry holds ro_b first) + pm.add_type("ro_b") + pm.add_type_parameter("ro_b", "IF", default=None, unit="Hz") + pm.add_type("ro_a") + pm.add_type_parameter("ro_a", "IF", default=None, unit="Hz") + pm.add_parameter("q01.readout.IF", unit="Hz") + + assert pm.types_of("q01.readout.IF") == ["ro_a", "ro_b"] + + +def test_an_empty_type_has_no_instances(pm): + pm.add_type("empty") + pm.add_parameter("q01.anything", unit="Hz") + + assert pm.instances_of("empty") == [] + + +def test_the_root_is_never_an_instance(pm): + pm.add_type("flat") + pm.add_type_parameter("flat", "IF", default=None, unit="Hz") + pm.add_parameter("IF", unit="Hz") + pm.add_parameter("under_group.IF", unit="Hz") + + # the root carries the shape but is not a submodule: only under_group + # is an Instance, and the root parameter is claimed by nothing + assert pm.instances_of("flat") == ["under_group"] + assert pm.types_of("IF") == [] + + +def test_nothing_under_the_globals_submodule_matches(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + # a Parameter Group directly under Globals and one deeper inside it + # both carry the whole shape with the right unit — and still match + # nothing, because matching excludes the Globals subtree (D12) + put_globals_parameter(pm, "_globals.qubit.IF", unit="Hz") + put_globals_parameter(pm, "_globals.deep.qubit.IF", unit="Hz") + pm.add_parameter("q09.IF", unit="Hz") + + assert pm.instances_of("qubit") == ["q09"] + assert pm.types_of("_globals.qubit.IF") == [] + assert pm.types_of("_globals.deep.qubit.IF") == [] + + +def test_values_are_irrelevant_to_matching(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.IF", initial_value=1, unit="Hz") + pm.add_parameter("q01.octave_gain", initial_value=999, unit="dB") + pm.add_parameter("q02.IF", initial_value=5e9, unit="Hz") + pm.add_parameter("q02.octave_gain", initial_value=0, unit="dB") + + # the values differ wildly between the two Instances; both match + assert sorted(pm.instances_of("qubit")) == ["q01", "q02"] + + +def test_a_type_nested_at_two_submodules_matches_only_whole_carriers(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "ro1", "readout") + pm.add_nested_type("qubit", "ro2", "readout") + pm.add_parameter("q01.ro1.IF", unit="Hz") + pm.add_parameter("q01.ro2.IF", unit="Hz") + # q02 carries only one of the two nested halves + pm.add_parameter("q02.ro1.IF", unit="Hz") + + # qubit's effective set is ro1.IF and ro2.IF: only q01 carries both + assert pm.instances_of("qubit") == ["q01"] + # each half is an Instance of readout on its own, q02.ro1 included + assert sorted(pm.instances_of("readout")) == [ + "q01.ro1", + "q01.ro2", + "q02.ro1", + ] + assert pm.types_of("q01.ro1.IF") == ["readout", "qubit"] + assert pm.types_of("q02.ro1.IF") == ["readout"] + + +def test_instances_of_with_an_unknown_type_raises_naming_it(pm): + with pytest.raises(ValueError, match="no Type named 'qubit' exists"): + pm.instances_of("qubit") + + +def test_types_of_with_an_unknown_path_raises_naming_it(pm): + pm.add_parameter("q01.IF", unit="Hz") + + with pytest.raises(ValueError, match="Parameter 'nope' does not exist"): + pm.types_of("nope") + # a Parameter Group path is not a parameter path + with pytest.raises(ValueError, match="Parameter 'q01' does not exist"): + pm.types_of("q01") + + +def test_types_of_returns_empty_for_an_unclaimed_parameter(pm): + pm.add_parameter("q01.extra", unit="s") + # with an empty registry nothing claims anything + assert pm.types_of("q01.extra") == [] + + put_three_tier_registry(pm) + put_three_tier_tree(pm) + + # the extra parameter is in no Type's effective set + assert pm.types_of("q01.extra") == [] + + +# --------------------------------------------------------------------------- +# Type edits with Instance side effects (plan task 2.3, D13) +# --------------------------------------------------------------------------- + + +def test_add_type_parameter_creates_the_parameter_in_every_instance_lacking_it(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", initial_value=11, unit="dB") + pm.add_parameter("q02.octave_gain", initial_value=12, unit="dB") + # a unit mismatch: q03 is no Instance (D12) and gets nothing + pm.add_parameter("q03.octave_gain", initial_value=13, unit="Hz") + assert pm.instances_of("qubit") == ["q01", "q02"] + + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + + # created with the entry's default value and unit (D13) + assert pm.get("q01.IF") == 5e9 + assert pm.get("q02.IF") == 5e9 + assert pm.parameter("q01.IF").unit == "Hz" + assert pm.parameter("q02.IF").unit == "Hz" + assert not pm.has_param("q03.IF") + # both Instances still match, now against the grown effective set + assert pm.instances_of("qubit") == ["q01", "q02"] + assert pm.get_type("qubit").parameters["IF"] == { + "default": 5e9, + "unit": "Hz", + "target": None, + } + + +def test_add_type_parameter_leaves_an_existing_parameter_at_the_path_alone(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + # q01 carries an extra IF with its own value and unit: extra parameters + # do not matter to matching, and the new entry must not overwrite it + pm.add_parameter("q01.IF", initial_value=1, unit="V") + pm.add_parameter("q01.octave_gain", initial_value=11, unit="dB") + pm.add_parameter("q02.octave_gain", initial_value=12, unit="dB") + assert pm.instances_of("qubit") == ["q01", "q02"] + + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + + # the existing parameter is left alone, whatever its unit + assert pm.get("q01.IF") == 1 + assert pm.parameter("q01.IF").unit == "V" + # q01 stops being an Instance: its IF does not carry the declared unit + assert pm.instances_of("qubit") == ["q02"] + + +def test_add_type_parameter_on_an_empty_type_creates_nothing(pm): + # an empty Type has no Instances (D12), so the edit has no side + # effects: only submodules that already carry the whole shape become + # Instances on the next matching query + pm.add_type("qubit") + pm.add_parameter("q01.anything", unit="Hz") + + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + + assert not pm.has_param("q01.IF") + assert pm.list() == ["q01.anything"] + assert pm.instances_of("qubit") == [] + + +def test_add_type_parameter_reaches_the_instances_of_types_nesting_it(pm): + # qubit nests the empty readout, super nests qubit at q: adding the + # first readout entry must write readout.window into every qubit- and + # super-Instance, creating the missing readout Parameter Groups on the + # way; without the outer reach, q01 and s01 would silently stop + # matching after the edit + pm.add_type("readout") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_type("super") + pm.add_type_parameter("super", "top", default=1, unit="") + pm.add_nested_type("super", "q", "qubit") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + pm.add_parameter("s01.top", initial_value=1, unit="") + pm.add_parameter("s01.q.octave_gain", initial_value=10, unit="dB") + assert pm.instances_of("qubit") == ["q01", "s01.q"] + assert pm.instances_of("super") == ["s01"] + assert pm.instances_of("readout") == [] + + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + + # each affected path is created once, although readout, qubit and + # super all reach it (their pairs de-duplicate to the same paths) + assert pm.has_param("q01.readout.window") + assert pm.has_param("s01.q.readout.window") + assert pm.get("q01.readout.window") == 2e-6 + assert pm.parameter("s01.q.readout.window").unit == "s" + assert pm.instances_of("readout") == ["q01.readout", "s01.q.readout"] + # every Instance still matches its Type against the grown sets + assert pm.instances_of("qubit") == ["q01", "s01.q"] + assert pm.instances_of("super") == ["s01"] + + +def test_add_type_parameter_writes_every_prefix_of_a_type_nested_twice(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "ro1", "readout") + pm.add_nested_type("qubit", "ro2", "readout") + pm.add_parameter("q01.ro1.IF", unit="Hz") + pm.add_parameter("q01.ro2.IF", unit="Hz") + assert pm.instances_of("qubit") == ["q01"] + + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + + # the entry is written under every submodule that requires readout + assert pm.has_param("q01.ro1.window") + assert pm.has_param("q01.ro2.window") + assert pm.get("q01.ro1.window") == 2e-6 + assert pm.instances_of("qubit") == ["q01"] + + +def test_add_type_parameter_refuses_a_path_the_type_already_defines(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + + with pytest.raises( + ValueError, + match=re.escape( + "parameter path 'IF' is already in the effective set of " + "Type 'qubit' (defined by Type 'qubit')" + ), + ): + pm.add_type_parameter("qubit", "IF", default=1, unit="V") + + assert pm.get_type("qubit").parameters["IF"] == { + "default": None, + "unit": "Hz", + "target": None, + } + + +def test_add_type_parameter_refuses_a_path_defined_by_a_nested_type(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape( + "parameter path 'readout.IF' is already in the effective set " + "of Type 'qubit' (defined by Type 'readout')" + ), + ): + pm.add_type_parameter("qubit", "readout.IF") + + assert pm.get_type("qubit").parameters == {} + + +def test_add_type_parameter_refuses_a_path_that_collides_in_a_nesting_type(pm): + # qubit nests the empty readout and owns the entry readout.window: + # adding window to readout would duplicate the path in qubit's + # effective set and break every query on qubit + pm.add_type("readout") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_type_parameter("qubit", "readout.window", default=None, unit="s") + + with pytest.raises( + ValueError, + match=re.escape( + "cannot add 'window' to Type 'readout': parameter path(s) " + "'readout.window' (in the effective set of Type 'qubit') " + "would appear more than once" + ), + ): + pm.add_type_parameter("readout", "window") + + # refused before any mutation + assert pm.get_type("readout").parameters == {} + assert pm.get_type("qubit").parameters == { + "readout.window": {"default": None, "unit": "s", "target": None} + } + + +def test_add_type_parameter_names_every_nesting_type_collision(pm): + # one nester nesting the edited Type at two submodules, owning an + # entry under each: both colliding paths are named (rule 3) + pm.add_type("inner") + pm.add_type("outer") + pm.add_nested_type("outer", "a", "inner") + pm.add_nested_type("outer", "b", "inner") + pm.add_type_parameter("outer", "a.x", default=None, unit="Hz") + pm.add_type_parameter("outer", "b.x", default=None, unit="Hz") + + with pytest.raises(ValueError) as excinfo: + pm.add_type_parameter("inner", "x") + + message = str(excinfo.value) + assert "'a.x' (in the effective set of Type 'outer')" in message + assert "'b.x' (in the effective set of Type 'outer')" in message + # refused before any mutation + assert pm.get_type("inner").parameters == {} + assert pm.get_type("outer").parameters == { + "a.x": {"default": None, "unit": "Hz", "target": None}, + "b.x": {"default": None, "unit": "Hz", "target": None}, + } + + +def test_add_type_parameter_refuses_a_target_blocked_by_a_parameter(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", unit="dB") + pm.add_parameter("q02.octave_gain", unit="dB") + + with pytest.raises(ValueError) as excinfo: + pm.add_type_parameter("qubit", "octave_gain.x", default=1, unit="s") + + # every offending path, not the first (rule 3) + message = str(excinfo.value) + assert "cannot create parameter 'q01.octave_gain.x'" in message + assert "cannot create parameter 'q02.octave_gain.x'" in message + assert ( + "'q01.octave_gain' is a parameter, and cannot have child parameters" in message + ) + assert ( + "'q02.octave_gain' is a parameter, and cannot have child parameters" in message + ) + # nothing was mutated + assert pm.get_type("qubit").parameters == { + "octave_gain": {"default": 10, "unit": "dB", "target": None} + } + assert pm.list() == ["q01.octave_gain", "q02.octave_gain"] + + +def test_add_type_parameter_refuses_a_target_blocked_by_a_parameter_group(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", unit="dB") + # the group q01.IF occupies the target path of the new entry + pm.add_parameter("q01.IF.sub", unit="s") + pm.add_parameter("q02.octave_gain", unit="dB") + + with pytest.raises( + ValueError, + match=re.escape( + "cannot create parameter 'q01.IF': 'q01.IF' is already a Parameter Group" + ), + ): + pm.add_type_parameter("qubit", "IF", default=1, unit="Hz") + + assert pm.get_type("qubit").parameters == { + "octave_gain": {"default": 10, "unit": "dB", "target": None} + } + assert not pm.has_param("q02.IF") + + +def test_add_type_parameter_refuses_a_path_with_empty_segments(pm): + pm.add_type("qubit") + + with pytest.raises(ValueError, match="is not a valid parameter path"): + pm.add_type_parameter("qubit", "") + with pytest.raises(ValueError, match="is not a valid parameter path"): + pm.add_type_parameter("qubit", "x..y") + + assert pm.get_type("qubit").parameters == {} + assert pm.list() == [] + + +def test_remove_type_parameter_leaves_the_instance_parameters_untouched(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.IF", initial_value=5e9, unit="Hz") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + + pm.remove_type_parameter("qubit", "IF") + + # the parameter stays on the Instance, with value and unit (D13) + assert pm.has_param("q01.IF") + assert pm.get("q01.IF") == 5e9 + assert pm.parameter("q01.IF").unit == "Hz" + # the registry lost the entry; the shrunken shape still matches q01, + # whose removed parameter simply became an untyped row + assert pm.get_type("qubit").parameters == { + "octave_gain": {"default": 10, "unit": "dB", "target": None} + } + assert pm.instances_of("qubit") == ["q01"] + assert pm.types_of("q01.IF") == [] + assert pm.types_of("q01.octave_gain") == ["qubit"] + + +def test_remove_type_parameter_refuses_a_path_only_reached_through_a_nested_type(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape( + "parameter path 'readout.IF' is not an entry of Type 'qubit' " + "itself: it is only in the effective set through the entry of " + "Type 'readout'" + ), + ): + pm.remove_type_parameter("qubit", "readout.IF") + + assert pm.get_type("qubit").nested == {"readout": "readout"} + assert pm.get_type("readout").parameters == { + "IF": {"default": None, "unit": "Hz", "target": None} + } + + +def test_remove_type_parameter_refuses_an_unknown_path(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + + with pytest.raises( + ValueError, + match=re.escape("parameter path 'nope' is not an entry of Type 'qubit'"), + ): + pm.remove_type_parameter("qubit", "nope") + + assert pm.get_type("qubit").parameters == { + "IF": {"default": None, "unit": "Hz", "target": None} + } + + +def test_set_type_parameter_default_changes_only_the_registry(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_parameter("q01.IF", initial_value=5e9, unit="Hz") + + pm.set_type_parameter_default("qubit", "IF", 6e9) + + assert pm.get_type("qubit").parameters["IF"]["default"] == 6e9 + # the Instance keeps its value (D13): only parameters created later + # start with the new default + assert pm.get("q01.IF") == 5e9 + assert pm.instances_of("qubit") == ["q01"] + + +def test_set_type_parameter_default_refuses_paths_that_are_not_its_own_entries(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape("'readout.IF' is not an entry of Type 'qubit' itself"), + ): + pm.set_type_parameter_default("qubit", "readout.IF", 1) + with pytest.raises( + ValueError, + match=re.escape("'nope' is not an entry of Type 'qubit'"), + ): + pm.set_type_parameter_default("qubit", "nope", 1) + + assert pm.get_type("readout").parameters["IF"]["default"] is None + + +def test_set_type_parameter_unit_propagates_to_every_instance(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_type("super") + pm.add_type_parameter("super", "top", default=1, unit="") + pm.add_nested_type("super", "q", "qubit") + pm.add_parameter("q01.octave_gain", unit="dB") + pm.add_parameter("q01.readout.IF", unit="Hz") + pm.add_parameter("s01.top", unit="") + pm.add_parameter("s01.q.octave_gain", unit="dB") + pm.add_parameter("s01.q.readout.IF", unit="Hz") + + pm.set_type_parameter_unit("readout", "IF", "V") + + # the entry and every Instance's parameter carry the new unit — the + # same parameter, whether reached through readout, qubit or super + assert pm.get_type("readout").parameters["IF"]["unit"] == "V" + assert pm.parameter("q01.readout.IF").unit == "V" + assert pm.parameter("s01.q.readout.IF").unit == "V" + # the Instances still match, now against the new unit + assert pm.instances_of("readout") == ["q01.readout", "s01.q.readout"] + assert pm.instances_of("qubit") == ["q01", "s01.q"] + assert pm.instances_of("super") == ["s01"] + # untouched parameters keep their unit + assert pm.parameter("q01.octave_gain").unit == "dB" + + +def test_set_type_parameter_unit_refuses_paths_that_are_not_its_own_entries(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape("'readout.IF' is not an entry of Type 'qubit' itself"), + ): + pm.set_type_parameter_unit("qubit", "readout.IF", "V") + with pytest.raises( + ValueError, + match=re.escape("'nope' is not an entry of Type 'qubit'"), + ): + pm.set_type_parameter_unit("qubit", "nope", "V") + + assert pm.get_type("readout").parameters["IF"]["unit"] == "Hz" + + +def test_add_nested_type_writes_the_nested_entries_into_the_instances(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + assert pm.instances_of("qubit") == ["q01"] + + pm.add_nested_type("qubit", "readout", "readout") + + # the nested entries are written under the submodule with their + # defaults and units (D13) + assert pm.has_param("q01.readout.IF") + assert pm.get("q01.readout.IF") == 10e6 + assert pm.get("q01.readout.window") == 2e-6 + assert pm.parameter("q01.readout.window").unit == "s" + assert pm.get_type("qubit").nested == {"readout": "readout"} + assert pm.instances_of("qubit") == ["q01"] + assert pm.instances_of("readout") == ["q01.readout"] + + +def test_add_nested_type_reaches_the_instances_of_outer_types(pm): + # qubit is empty when super nests it, so the super-Instance s01 has no + # q group at all: nesting readout into qubit must create + # s01.q.readout.IF through the missing Parameter Groups + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type("qubit") + pm.add_type("super") + pm.add_type_parameter("super", "top", default=1, unit="") + pm.add_nested_type("super", "q", "qubit") + pm.add_parameter("s01.top", initial_value=1, unit="") + assert pm.instances_of("qubit") == [] + assert pm.instances_of("super") == ["s01"] + + pm.add_nested_type("qubit", "readout", "readout") + + assert pm.has_param("s01.q.readout.IF") + assert pm.get("s01.q.readout.IF") == 10e6 + assert pm.parameter("s01.q.readout.IF").unit == "Hz" + assert pm.instances_of("qubit") == ["s01.q"] + assert pm.instances_of("super") == ["s01"] + assert pm.instances_of("readout") == ["s01.q.readout"] + + +def test_add_nested_type_builds_the_three_tier_case(pm): + # a Nested Type that itself nests a Type, built entirely through the + # public API: nesting readout into qubit writes readout's whole + # effective set, pulse_window's entries included + pm.add_type("pulse_window") + pm.add_type_parameter("pulse_window", "duration", default=None, unit="s") + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_nested_type("readout", "pw", "pulse_window") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + + pm.add_nested_type("qubit", "readout", "readout") + + assert pm.has_param("q01.readout.IF") + assert pm.get("q01.readout.IF") is None + assert pm.parameter("q01.readout.IF").unit == "Hz" + assert pm.has_param("q01.readout.pw.duration") + assert pm.get("q01.readout.pw.duration") is None + assert pm.parameter("q01.readout.pw.duration").unit == "s" + assert pm.instances_of("qubit") == ["q01"] + assert pm.instances_of("readout") == ["q01.readout"] + assert pm.instances_of("pulse_window") == ["q01.readout.pw"] + + +def test_add_nested_type_leaves_an_existing_parameter_at_the_target_alone(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + # q01 carries a readout.IF of its own, with another value and unit + pm.add_parameter("q01.readout.IF", initial_value=1, unit="V") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + assert pm.instances_of("qubit") == ["q01"] + + pm.add_nested_type("qubit", "readout", "readout") + + # the existing parameter is left alone, whatever its unit + assert pm.get("q01.readout.IF") == 1 + assert pm.parameter("q01.readout.IF").unit == "V" + # q01 stops being an Instance: its readout.IF does not carry the unit + # the entry declares + assert pm.instances_of("qubit") == [] + + +def test_add_nested_type_accepts_a_dotted_submodule_name(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + + pm.add_nested_type("qubit", "ro.deep", "readout") + + assert pm.get_type("qubit").nested == {"ro.deep": "readout"} + assert pm._effective_parameters("qubit")["ro.deep.IF"] == { + "unit": "Hz", + "from_type": "readout", + } + # the entries are written under the dotted submodule, with the entry + # default and unit + assert pm.get("q01.ro.deep.IF") == 10e6 + assert pm.parameter("q01.ro.deep.IF").unit == "Hz" + assert pm.instances_of("qubit") == ["q01"] + + # removing the Nested Type is symmetric + pm.remove_nested_type("qubit", "ro.deep") + assert pm.get_type("qubit").nested == {} + # the created parameter stays (D13) + assert pm.has_param("q01.ro.deep.IF") + + +def test_add_nested_type_refuses_a_self_nesting(pm): + pm.add_type("loop") + + with pytest.raises( + ValueError, + match=re.escape("cycle in nested Types: loop -> loop"), + ): + pm.add_nested_type("loop", "self", "loop") + + assert pm.get_type("loop").nested == {} + + +def test_add_nested_type_refuses_a_longer_cycle(pm): + pm.add_type("readout") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape("cycle in nested Types: readout -> qubit -> readout"), + ): + pm.add_nested_type("readout", "qubit", "qubit") + + assert pm.get_type("readout").nested == {} + assert pm.get_type("qubit").nested == {"readout": "readout"} + + +def test_add_nested_type_refuses_an_occupied_submodule(pm): + pm.add_type("readout") + pm.add_type("pulse_window") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + + with pytest.raises( + ValueError, + match=re.escape( + "submodule 'readout' of Type 'qubit' already requires the " + "Nested Type 'readout'" + ), + ): + pm.add_nested_type("qubit", "readout", "pulse_window") + + assert pm.get_type("qubit").nested == {"readout": "readout"} + + +def test_add_nested_type_refuses_a_target_blocked_by_a_parameter_group(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", unit="dB") + # the Parameter Group q01.readout.IF occupies the target path of the + # nested entry IF + pm.add_parameter("q01.readout.IF.sub", unit="s") + + with pytest.raises( + ValueError, + match=re.escape( + "cannot create parameter 'q01.readout.IF': 'q01.readout.IF' " + "is already a Parameter Group" + ), + ): + pm.add_nested_type("qubit", "readout", "readout") + + # nothing was mutated + assert pm.get_type("qubit").nested == {} + assert pm.list() == ["q01.readout.IF.sub", "q01.octave_gain"] + + +def test_add_nested_type_refuses_conflicting_creation_targets(pm): + # the Nested Type's effective set holds b and the strict extension + # b.c: the one edit would create the parameter q01.s.b and need it as + # a Parameter Group for q01.s.b.c — refused before anything is + # mutated, after which the creation would have raised mid-way + pm.add_type("leaf") + pm.add_type_parameter("leaf", "c", default=1, unit="V") + pm.add_type("branched") + pm.add_type_parameter("branched", "b", default=2, unit="A") + pm.add_nested_type("branched", "b", "leaf") + pm.add_type("outer") + pm.add_type_parameter("outer", "top", default=0, unit="") + pm.add_parameter("q01.top", initial_value=0, unit="") + assert pm.instances_of("outer") == ["q01"] + + with pytest.raises(ValueError) as excinfo: + pm.add_nested_type("outer", "s", "branched") + + # the offending pair is named, both paths (rule 3) + message = str(excinfo.value) + assert "cannot create parameter 'q01.s.b.c'" in message + assert "'q01.s.b' is also created by this edit" in message + # refused before any mutation + assert pm.get_type("outer").nested == {} + assert pm.list() == ["q01.top"] + + +def test_add_nested_type_refuses_a_submodule_name_with_empty_segments(pm): + pm.add_type("readout") + pm.add_type("qubit") + + with pytest.raises( + ValueError, + match=re.escape("'' is not a valid submodule name for a Nested Type"), + ): + pm.add_nested_type("qubit", "", "readout") + with pytest.raises( + ValueError, + match=re.escape("'x..y' is not a valid submodule name for a Nested Type"), + ): + pm.add_nested_type("qubit", "x..y", "readout") + + assert pm.get_type("qubit").nested == {} + + +def test_add_nested_type_refuses_a_duplicated_effective_path(pm): + # qubit's own entry readout.IF would collide with the readout entry, + # both in qubit's effective set and in the one of super, which nests + # qubit at q + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "readout.IF", default=None, unit="Hz") + pm.add_type("super") + pm.add_nested_type("super", "q", "qubit") + + with pytest.raises(ValueError) as excinfo: + pm.add_nested_type("qubit", "readout", "readout") + + # every offending path, not the first (rule 3) + message = str(excinfo.value) + assert "'readout.IF' (in the effective set of Type 'qubit')" in message + assert "'q.readout.IF' (in the effective set of Type 'super')" in message + assert pm.get_type("qubit").nested == {} + assert pm.get_type("qubit").parameters == { + "readout.IF": {"default": None, "unit": "Hz", "target": None} + } + + +def test_add_nested_type_refuses_the_globals_submodule(pm): + pm.add_type("readout") + pm.add_type("qubit") + + with pytest.raises( + ValueError, + match=re.escape("the Globals submodule name is reserved"), + ): + pm.add_nested_type("qubit", "_globals", "readout") + + assert pm.get_type("qubit").nested == {} + + +def test_add_nested_type_with_an_unknown_type_raises_naming_it(pm): + pm.add_type("readout") + + with pytest.raises(ValueError, match="no Type named 'qubit' exists"): + pm.add_nested_type("qubit", "readout", "readout") + # both missing names appear in one error (rule 3) + with pytest.raises(ValueError) as excinfo: + pm.add_nested_type("qubit", "ro", "pulse_window") + assert "no Type named 'qubit' exists" in str(excinfo.value) + assert "no Type named 'pulse_window' exists" in str(excinfo.value) + + assert pm.list_types() == ["readout"] + + +def test_remove_nested_type_leaves_the_parameters_untouched(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_parameter("q01.octave_gain", unit="dB") + pm.add_parameter("q01.readout.IF", unit="Hz") + + pm.remove_nested_type("qubit", "readout") + + # every parameter stays (D13); the shrunken shape still matches q01, + # whose readout parameter simply became an untyped row + assert pm.has_param("q01.readout.IF") + assert pm.parameter("q01.readout.IF").unit == "Hz" + assert pm.get_type("qubit").nested == {} + assert pm.instances_of("qubit") == ["q01"] + assert pm.instances_of("readout") == ["q01.readout"] + assert pm.types_of("q01.readout.IF") == ["readout"] + + +def test_remove_nested_type_refuses_a_submodule_without_one(pm): + pm.add_type("qubit") + + with pytest.raises( + ValueError, + match=re.escape("submodule 'readout' of Type 'qubit' has no Nested Type"), + ): + pm.remove_nested_type("qubit", "readout") + + +def test_remove_type_leaves_the_parameters_untouched(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_parameter("q01.IF", initial_value=5e9, unit="Hz") + + pm.remove_type("qubit") + + # deleting the Type touches no parameters (D13); the row is untyped + assert pm.has_param("q01.IF") + assert pm.get("q01.IF") == 5e9 + assert pm.types_of("q01.IF") == [] + + +def test_a_failed_validation_leaves_the_tree_and_registry_byte_identical(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + pm.add_parameter("q01.readout.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01.readout.window", initial_value=2e-6, unit="s") + pm.add_parameter("q02.octave_gain", initial_value=11, unit="dB") + pm.add_parameter("q02.readout.IF", initial_value=20e6, unit="Hz") + # a Nested Type whose effective set holds b and the strict extension + # b.c, built through the public API while it has no Instances + pm.add_type("leaf") + pm.add_type_parameter("leaf", "c", default=1, unit="V") + pm.add_type("branched") + pm.add_type_parameter("branched", "b", default=2, unit="A") + pm.add_nested_type("branched", "b", "leaf") + # an empty Nested Type whose nester owns an entry under it, at two + # submodules: adding the entry to the empty Type collides twice + pm.add_type("bare") + pm.add_type("holder") + pm.add_type_parameter("holder", "bare.window", default=None, unit="s") + pm.add_nested_type("holder", "bare", "bare") + pm.add_nested_type("holder", "bare2", "bare") + pm.add_type_parameter("holder", "bare2.window", default=None, unit="s") + # an outer Type with an Instance, for the refused nesting below + pm.add_type("outer") + pm.add_type_parameter("outer", "top", default=0, unit="") + pm.add_parameter("q01.top", initial_value=0, unit="") + # shapes and parameters for the add_instance refusals below + pm.add_type("sensor") + pm.add_type_parameter("sensor", "sub.x", default=1, unit="V") + pm.add_type_parameter("sensor", "sub.y", default=2, unit="A") + pm.add_parameter("q03.octave_gain", initial_value=1, unit="V") + pm.add_parameter("q04.sub", initial_value=0, unit="V") + pm.add_parameter("q05.readout.window.sub", initial_value=0, unit="s") + + def state(): + return ( + sorted(pm.list()), + {path: pm.get(path) for path in pm.list()}, + {path: pm.parameter(path).unit for path in pm.list()}, + copy.deepcopy(pm._types), + ) + + before = state() + failing_calls = [ + lambda: pm.add_type_parameter("qubit", "octave_gain"), + lambda: pm.add_type_parameter("qubit", "readout.IF"), + lambda: pm.add_type_parameter("nope", "x"), + lambda: pm.add_type_parameter("qubit", ""), + # q02.octave_gain is a parameter and blocks the target path + lambda: pm.add_type_parameter("qubit", "octave_gain.x", default=1, unit="s"), + # the path collides in the effective set of holder, which nests + # the empty bare at bare and bare2 and owns an entry under each: + # both colliding paths are named + lambda: pm.add_type_parameter("bare", "window"), + lambda: pm.remove_type_parameter("qubit", "readout.IF"), + lambda: pm.remove_type_parameter("qubit", "nope"), + lambda: pm.set_type_parameter_default("qubit", "readout.IF", 1), + lambda: pm.set_type_parameter_default("qubit", "nope", 1), + lambda: pm.set_type_parameter_unit("qubit", "readout.IF", "V"), + lambda: pm.set_type_parameter_unit("qubit", "nope", "V"), + lambda: pm.add_nested_type("qubit", "readout", "readout"), + lambda: pm.add_nested_type("qubit", "readout", "nope"), + lambda: pm.add_nested_type("qubit", "_globals", "readout"), + lambda: pm.add_nested_type("qubit", "ro", "nope"), + lambda: pm.add_nested_type("nope", "s", "readout"), + lambda: pm.add_nested_type("readout", "qubit", "qubit"), + lambda: pm.add_nested_type("readout", "self", "readout"), + # q01.octave_gain is a parameter and blocks the nested targets + lambda: pm.add_nested_type("qubit", "octave_gain", "readout"), + # the edit would create q01.s.b and need it as a Parameter Group + # for q01.s.b.c: the targets of one edit conflict with each other + lambda: pm.add_nested_type("outer", "s", "branched"), + lambda: pm.add_nested_type("qubit", "", "readout"), + lambda: pm.add_nested_type("qubit", "x..y", "readout"), + lambda: pm.remove_nested_type("qubit", "pw"), + lambda: pm.remove_nested_type("nope", "readout"), + # add_instance refusals (task 2.4): an unknown Type, an invalid + # name, the reserved Globals name, a unit conflict (q03.octave_gain + # carries unit V where qubit declares dB), a target blocked by a + # parameter (q04.sub), a target occupied by a Parameter Group + # (q05.readout.window), and the conflicting targets b / b.c of + # branched + lambda: pm.add_instance("nope", "q01"), + lambda: pm.add_instance("qubit", ""), + lambda: pm.add_instance("qubit", "x..y"), + lambda: pm.add_instance("qubit", "_globals"), + lambda: pm.add_instance("qubit", "_globals.deep"), + lambda: pm.add_instance("qubit", "q03"), + lambda: pm.add_instance("sensor", "q04"), + lambda: pm.add_instance("qubit", "q05"), + lambda: pm.add_instance("branched", "q06"), + ] + for call in failing_calls: + with pytest.raises(ValueError): + call() + # the refused call left list, every value and unit, and the Type + # registry byte-identical + assert state() == before + + +# --------------------------------------------------------------------------- +# add_instance (plan task 2.4, D14) +# --------------------------------------------------------------------------- + + +def test_add_instance_creates_the_missing_entries_with_defaults_and_units(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + + pm.add_instance("qubit", "q01") + + # every effective path is created with the entry default and unit + assert pm.get("q01.IF") == 5e9 + assert pm.parameter("q01.IF").unit == "Hz" + assert pm.get("q01.octave_gain") == 10 + assert pm.parameter("q01.octave_gain").unit == "dB" + # created through the ordinary add_parameter path: ManagedParameters + # that can carry a Lock, with the full dotted path + assert isinstance(pm.parameter("q01.IF"), ManagedParameter) + assert pm.parameter("q01.IF").path == "parameter_manager.q01.IF" + # after the call the submodule carries the whole shape (D14) + assert pm.instances_of("qubit") == ["q01"] + + +def test_add_instance_keeps_existing_entries_with_value_and_unit(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.IF", initial_value=6e9, unit="Hz") + + pm.add_instance("qubit", "q01") + + # the existing parameter keeps its own value and unit + assert pm.get("q01.IF") == 6e9 + assert pm.parameter("q01.IF").unit == "Hz" + # the missing one is created + assert pm.get("q01.octave_gain") == 10 + assert pm.instances_of("qubit") == ["q01"] + + +def test_add_instance_accepts_a_dotted_name(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + + pm.add_instance("readout", "q02.ro") + + # the Parameter Groups on the way are created + assert pm.get("q02.ro.IF") == 10e6 + assert pm.parameter("q02.ro.IF").unit == "Hz" + assert pm.instances_of("readout") == ["q02.ro"] + + +def test_add_instance_builds_the_three_tier_case(pm): + put_three_tier_registry(pm) + + pm.add_instance("qubit", "q01") + + # every effective path of the whole nesting chain is created with the + # entry default and unit + assert pm.get("q01.IF") is None + assert pm.parameter("q01.IF").unit == "Hz" + assert pm.get("q01.octave_gain") == 10 + assert pm.parameter("q01.octave_gain").unit == "dB" + assert pm.get("q01.readout.IF") is None + assert pm.parameter("q01.readout.IF").unit == "Hz" + assert pm.get("q01.readout.window") is None + assert pm.parameter("q01.readout.window").unit == "s" + assert pm.get("q01.readout.pw.duration") is None + assert pm.parameter("q01.readout.pw.duration").unit == "s" + # q01 matches at every tier + assert pm.instances_of("qubit") == ["q01"] + assert pm.instances_of("readout") == ["q01.readout"] + assert pm.instances_of("pulse_window") == ["q01.readout.pw"] + + +def test_add_instance_refuses_a_unit_conflict_naming_every_conflicting_path(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.IF", initial_value=1, unit="V") + pm.add_parameter("q01.octave_gain", initial_value=2, unit="W") + + with pytest.raises(ValueError) as excinfo: + pm.add_instance("qubit", "q01") + + # every conflicting path with both units, not the first (rule 3) + message = str(excinfo.value) + assert "cannot add an Instance of Type 'qubit' at 'q01'" in message + assert "'q01.IF' carries unit 'V', the Type declares 'Hz'" in message + assert "'q01.octave_gain' carries unit 'W', the Type declares 'dB'" in message + # the scan refuses before anything is created (D14) + assert pm.list() == ["q01.IF", "q01.octave_gain"] + assert pm.instances_of("qubit") == [] + + +def test_add_instance_refuses_a_unit_conflict_on_a_nested_effective_path(pm): + # the conflict sits on readout.IF: an effective path of qubit reached + # through the Nested Type, so the scan must walk the expanded set + put_three_tier_registry(pm) + pm.add_parameter("q01.readout.IF", initial_value=1, unit="V") + + with pytest.raises(ValueError) as excinfo: + pm.add_instance("qubit", "q01") + + # the full dotted path relative to the Parameter Manager, with both units + message = str(excinfo.value) + assert "cannot add an Instance of Type 'qubit' at 'q01'" in message + assert "'q01.readout.IF' carries unit 'V', the Type declares 'Hz'" in message + # the scan refuses before anything is created (D14) + assert pm.list() == ["q01.readout.IF"] + assert pm.instances_of("qubit") == [] + + +def test_add_instance_refuses_a_target_blocked_by_a_parameter(pm): + pm.add_type("sensor") + pm.add_type_parameter("sensor", "sub.x", default=1, unit="V") + pm.add_type_parameter("sensor", "sub.y", default=2, unit="A") + pm.add_parameter("q01.sub", initial_value=0, unit="V") + + with pytest.raises(ValueError) as excinfo: + pm.add_instance("sensor", "q01") + + # every offending target path, not the first (rule 3) + message = str(excinfo.value) + assert "cannot create parameter 'q01.sub.x'" in message + assert "cannot create parameter 'q01.sub.y'" in message + assert "'q01.sub' is a parameter, and cannot have child parameters" in message + # nothing was created + assert pm.list() == ["q01.sub"] + + +def test_add_instance_refuses_a_name_blocked_by_a_parameter(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + # the root parameter q01 takes the place the Instance needs + pm.add_parameter("q01", initial_value=0, unit="s") + + with pytest.raises(ValueError) as excinfo: + pm.add_instance("qubit", "q01") + + message = str(excinfo.value) + assert "cannot create parameter 'q01.IF'" in message + assert "'q01' is a parameter, and cannot have child parameters" in message + # a deeper name is blocked by the same parameter + with pytest.raises(ValueError) as excinfo: + pm.add_instance("qubit", "q01.ro") + assert "cannot create parameter 'q01.ro.IF'" in str(excinfo.value) + + assert pm.list() == ["q01"] + + +def test_add_instance_refuses_a_target_blocked_by_a_parameter_group(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + # the Parameter Group q01.IF occupies the target path of the entry IF + pm.add_parameter("q01.IF.sub", unit="s") + + with pytest.raises(ValueError) as excinfo: + pm.add_instance("qubit", "q01") + + message = str(excinfo.value) + assert "cannot create parameter 'q01.IF'" in message + assert "'q01.IF' is already a Parameter Group" in message + # nothing was created + assert pm.list() == ["q01.IF.sub"] + + +def test_add_instance_refuses_conflicting_creation_targets(pm): + # the Type's effective set holds b and the strict extension b.c + # (buildable while the Type has no Instances): the one call would + # create q01.b and need it as a Parameter Group for q01.b.c + pm.add_type("leaf") + pm.add_type_parameter("leaf", "c", default=1, unit="V") + pm.add_type("branched") + pm.add_type_parameter("branched", "b", default=2, unit="A") + pm.add_nested_type("branched", "b", "leaf") + + with pytest.raises(ValueError) as excinfo: + pm.add_instance("branched", "q01") + + message = str(excinfo.value) + assert "cannot create parameter 'q01.b.c'" in message + assert "'q01.b' is also created by this edit" in message + # nothing was created + assert pm.list() == [] + + +def test_add_instance_refuses_the_globals_submodule(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + + with pytest.raises( + ValueError, + match=re.escape( + "'_globals' is not a valid submodule path for an Instance: " + "the Globals submodule name is reserved" + ), + ): + pm.add_instance("qubit", "_globals") + # anything under the Globals submodule is refused too, naming its own + # offending name (D18) + with pytest.raises( + ValueError, + match=re.escape( + "'_globals.q01' is not a valid submodule path for an Instance: " + "the Globals submodule name is reserved" + ), + ): + pm.add_instance("qubit", "_globals.q01") + + # nothing was created + assert pm.list() == [] + + +def test_add_instance_refuses_the_globals_submodule_on_an_empty_type(pm): + # the name refusals are unconditional: even an empty Type, which + # creates nothing anyway, refuses the reserved Globals name (D18) + pm.add_type("empty") + + with pytest.raises( + ValueError, + match=re.escape( + "'_globals' is not a valid submodule path for an Instance: " + "the Globals submodule name is reserved" + ), + ): + pm.add_instance("empty", "_globals") + with pytest.raises( + ValueError, + match=re.escape( + "'_globals.q01' is not a valid submodule path for an Instance: " + "the Globals submodule name is reserved" + ), + ): + pm.add_instance("empty", "_globals.q01") + + # nothing was created + assert pm.list() == [] + + +def test_add_instance_refuses_a_name_with_empty_segments(pm): + pm.add_type("qubit") + + with pytest.raises( + ValueError, + match=re.escape("'' is not a valid submodule path for an Instance"), + ): + pm.add_instance("qubit", "") + with pytest.raises( + ValueError, + match=re.escape("'x..y' is not a valid submodule path for an Instance"), + ): + pm.add_instance("qubit", "x..y") + + assert pm.list() == [] + + +def test_add_instance_with_an_unknown_type_raises_naming_it(pm): + with pytest.raises(ValueError, match="no Type named 'qubit' exists"): + pm.add_instance("qubit", "q01") + + assert pm.list() == [] + + +def test_add_instance_on_an_empty_type_creates_nothing(pm): + pm.add_type("empty") + + pm.add_instance("empty", "q01") + + # no parameter and no Parameter Group was created; an empty Type has + # no Instances (D12), so q01 is not listed either + assert pm.list() == [] + assert "q01" not in pm.submodules + assert pm.instances_of("empty") == [] + + +# --------------------------------------------------------------------------- +# Globals rules and _ensure_global_target (plan task 3.1, D18) +# +# The reserved Globals submodule ``_globals`` holds the default Targets of +# Type Locks. The public ``add_parameter`` refuses its name (nothing is +# created), ``add_instance`` under it keeps raising (2.4), and matching +# keeps excluding it (2.2). The internal helper ``_ensure_global_target`` +# creates ``_globals..`` on demand, with the entry's default +# and unit; the parameter is otherwise ordinary. +# --------------------------------------------------------------------------- + + +def test_add_parameter_refuses_the_globals_submodule(pm): + pm.add_parameter("q01.IF", unit="Hz") + before = sorted(pm.list()) + + for name in ("_globals", "_globals.x", "_globals.qubit.IF"): + with pytest.raises( + ValueError, + match=re.escape(f"'{name}' is not a valid parameter path"), + ): + pm.add_parameter(name) + + # nothing was created: the tree is byte-identical, and the Globals + # submodule does not exist + assert sorted(pm.list()) == before + assert "_globals" not in pm.submodules + + +def test_add_parameter_refusal_covers_the_parameter_group_routing(pm): + # a Parameter Group routes add_parameter to the root (D15), so the + # refusal on the root covers the pm._globals.add_parameter(...) call + # style too + put_globals_parameter(pm, "_globals.qubit.IF", unit="Hz") + pm.add_parameter("q01.IF", unit="Hz") + before = sorted(pm.list()) + + with pytest.raises( + ValueError, + match=re.escape("'_globals.qubit.IF' is not a valid parameter path"), + ): + pm._globals.add_parameter("qubit.IF") + + # the refusal does not over-fire: a routed call without the Globals + # name still adds the parameter + pm.q01.add_parameter("sub", unit="s") + + assert sorted(pm.list()) == sorted(before + ["q01.sub"]) + + +def test_add_instance_still_refuses_the_globals_submodule(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + pm._ensure_global_target("qubit", "IF") + before = sorted(pm.list()) + + with pytest.raises(ValueError, match="the Globals submodule name is reserved"): + pm.add_instance("qubit", "_globals") + with pytest.raises(ValueError, match="the Globals submodule name is reserved"): + pm.add_instance("qubit", "_globals.q01") + + # nothing was created + assert sorted(pm.list()) == before + + +def test_globals_parameters_stay_excluded_from_matching(pm): + # a Globals parameter is never an Instance and is claimed by nothing + # (D12, D18) — even when the Globals subtree carries a full Instance + # shape, with every effective path at the declared unit + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm._ensure_global_target("qubit", "IF") + pm._ensure_global_target("qubit", "octave_gain") + put_globals_parameter(pm, "_globals.deep.qubit.IF", unit="Hz") + put_globals_parameter(pm, "_globals.deep.qubit.octave_gain", unit="dB") + + assert pm.instances_of("qubit") == [] + assert pm.types_of("_globals.qubit.IF") == [] + assert pm.types_of("_globals.qubit.octave_gain") == [] + assert pm.types_of("_globals.deep.qubit.IF") == [] + + +def test_ensure_global_target_creates_the_parameter_with_default_and_unit(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + + path = pm._ensure_global_target("qubit", "IF") + + # the relative dotted path is returned (rule 4: names are strings) + assert path == "_globals.qubit.IF" + assert pm.has_param(path) + assert pm.get(path) == 5e9 + assert pm.parameter(path).unit == "Hz" + # created as a ManagedParameter with the full dotted path, the way + # add_parameter does + param = pm.parameter(path) + assert isinstance(param, ManagedParameter) + assert param.path == "parameter_manager._globals.qubit.IF" + # the Parameter Groups on the way were created + assert "qubit" in pm._globals.submodules + + # a dotted entry path creates the Parameter Groups on the way too + pm.add_type("sensor") + pm.add_type_parameter("sensor", "sub.x", default=1, unit="V") + assert pm._ensure_global_target("sensor", "sub.x") == "_globals.sensor.sub.x" + assert pm.get("_globals.sensor.sub.x") == 1 + assert pm.parameter("_globals.sensor.sub.x").unit == "V" + + +def test_ensure_global_target_emits_one_creation_and_is_idempotent(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + received = [] + pm.add_broadcast_sink(received.append) + + path = pm._ensure_global_target("qubit", "IF") + + # exactly one parameter-creation, in the same shape the Type-edit + # side-effect creations use (D22, ADR-0003); it edits no Type, so no + # pm-type-update goes out + assert len(received) == 1 + bp = received[0] + assert bp.name == "parameter_manager._globals.qubit.IF" + assert bp.action == PARAMETER_CREATION + assert bp.value == 5e9 + assert bp.unit == "Hz" + + received.clear() + pm.set(path, 42.0) + assert pm._ensure_global_target("qubit", "IF") == path + # created on demand: the existing parameter is kept untouched, its own + # value stands, and nothing is emitted (D18) + assert pm.get(path) == 42.0 + assert received == [] + + +def test_ensure_global_target_refuses_an_unknown_type(pm): + pm.add_parameter("q01.IF", unit="Hz") + before = sorted(pm.list()) + + with pytest.raises(ValueError, match="no Type named 'nope' exists"): + pm._ensure_global_target("nope", "IF") + + assert sorted(pm.list()) == before + assert "_globals" not in pm.submodules + + +def test_ensure_global_target_refuses_a_path_that_is_not_an_own_entry(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=None, unit="Hz") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + before = sorted(pm.list()) + + with pytest.raises( + ValueError, + match=re.escape("parameter path 'nope' is not an entry of Type 'qubit'"), + ): + pm._ensure_global_target("qubit", "nope") + # a path only reached through a Nested Type is refused too: own entries + # only, like set_type_parameter_default and set_type_parameter_unit + with pytest.raises( + ValueError, + match=re.escape( + "parameter path 'readout.IF' is not an entry of Type 'qubit' " + "itself: it is only in the effective set through the entry of " + "Type 'readout'" + ), + ): + pm._ensure_global_target("qubit", "readout.IF") + + assert sorted(pm.list()) == before + assert "_globals" not in pm.submodules + + +def test_ensure_global_target_refuses_a_unit_conflict_on_the_existing_parameter(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + # an existing Globals parameter carrying a unit the entry does not + # declare: unit conflicts are refused like D14 + put_globals_parameter(pm, "_globals.qubit.IF", unit="V") + before = sorted(pm.list()) + + with pytest.raises(ValueError) as excinfo: + pm._ensure_global_target("qubit", "IF") + + # the path and both units are named + message = str(excinfo.value) + assert "'_globals.qubit.IF'" in message + assert "'V'" in message + assert "'Hz'" in message + # refused before anything is touched: the parameter keeps its unit + assert sorted(pm.list()) == before + assert pm.parameter("_globals.qubit.IF").unit == "V" + + +def test_ensure_global_target_refuses_a_parameter_on_the_way(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + # a parameter takes the place the Globals Parameter Group qubit needs + put_globals_parameter(pm, "_globals.qubit") + before = sorted(pm.list()) + + with pytest.raises( + ValueError, + match=re.escape( + "'_globals.qubit' is a parameter, and cannot have child parameters" + ), + ): + pm._ensure_global_target("qubit", "IF") + + assert sorted(pm.list()) == before + + +def test_ensure_global_target_refuses_a_parameter_group_at_the_target(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=None, unit="Hz") + # the Parameter Group _globals.qubit.IF occupies the target path + put_globals_parameter(pm, "_globals.qubit.IF.sub", unit="s") + before = sorted(pm.list()) + + with pytest.raises( + ValueError, + match=re.escape("'_globals.qubit.IF' is already a Parameter Group"), + ): + pm._ensure_global_target("qubit", "IF") + + assert sorted(pm.list()) == before + + +def test_a_globals_parameter_is_an_ordinary_parameter(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_parameter("q01.IF", initial_value=4e9, unit="Hz") + path = pm._ensure_global_target("qubit", "IF") + + # set and read like any parameter of the Parameter Manager (D18) + pm.set(path, 6e9) + assert pm.get(path) == 6e9 + + # and it can be the Target of a Lock: the Follower pulls its value + pm.lock("q01.IF", path) + assert pm.get("q01.IF") == 6e9 + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.followers_of(path) == ["q01.IF"] + + +# --------------------------------------------------------------------------- +# Type Locks: lock_type_parameter / unlock_type_parameter (plan task 3.2, D17) +# +# A Type Lock is a rule on a Type entry naming a Target. Declaring it +# stores the Target on the entry (the default is the Globals parameter +# ``_globals..``, created on demand) and puts an ordinary, +# locked Lock on the entry's parameter in every current Instance; +# Followers that carry a Lock on another Target are skipped with one +# warning and returned. Removing the Type Lock removes only the rule; +# every new Instance gets the existing Type Locks of its Type at +# creation. +# --------------------------------------------------------------------------- + + +def put_qubit_instances(pm): + """The Type ``qubit`` (entries IF and octave_gain) with the Instances + q01 and q02 carrying the whole shape.""" + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.IF", initial_value=5e9, unit="Hz") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + pm.add_parameter("q02.IF", initial_value=6e9, unit="Hz") + pm.add_parameter("q02.octave_gain", initial_value=11, unit="dB") + + +def lock_state(pm): + """Everything a refused Type Lock call must leave byte-identical: the + parameter tree, every Lock and the Type registry.""" + return ( + sorted(pm.list()), + pm.list_locks(), + copy.deepcopy(pm._types), + ) + + +def test_lock_type_parameter_locks_every_current_instance_parameter(pm): + put_qubit_instances(pm) + + skipped = pm.lock_type_parameter("qubit", "IF") + + assert skipped == [] + # every Instance parameter is locked to the default Globals Target + for inst in ("q01", "q02"): + assert pm.get_lock(f"{inst}.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + # the Followers pull the Target's value and refuse set + pm.set("_globals.qubit.IF", 7e9) + assert pm.get("q01.IF") == 7e9 + assert pm.get("q02.IF") == 7e9 + with pytest.raises( + ValueError, + match=re.escape( + "parameter_manager.q01.IF is locked to parameter_manager._globals.qubit.IF" + ), + ): + pm.set("q01.IF", 1) + # the entry carries the Target in the full form; the other entry is + # untouched + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "parameter_manager._globals.qubit.IF" + ) + assert pm.get_type("qubit").parameters["octave_gain"]["target"] is None + + +def test_lock_type_parameter_creates_the_default_globals_target(pm): + put_qubit_instances(pm) + + pm.lock_type_parameter("qubit", "IF") + + # the default Target _globals.. with the entry's default + # and unit + assert pm.has_param("_globals.qubit.IF") + assert pm.get("_globals.qubit.IF") == 5e9 + assert pm.parameter("_globals.qubit.IF").unit == "Hz" + assert isinstance(pm.parameter("_globals.qubit.IF"), ManagedParameter) + + +def test_lock_type_parameter_with_no_instances_stores_the_rule(pm): + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + + skipped = pm.lock_type_parameter("qubit", "IF") + + # no current Instances, so nothing is locked — but the rule is stored + # and the default Globals Target is created + assert skipped == [] + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "parameter_manager._globals.qubit.IF" + ) + assert pm.has_param("_globals.qubit.IF") + assert pm.list_locks() == {} + # the stored Target persists: a later Instance gets the Lock at creation + pm.add_instance("qubit", "q01") + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + + +def test_lock_type_parameter_skips_a_lock_on_another_target_with_a_warning(pm, caplog): + put_qubit_instances(pm) + pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") + pm.lock("q01.IF", "q00.IF") + caplog.clear() + + with caplog.at_level(logging.WARNING): + skipped = pm.lock_type_parameter("qubit", "IF") + + # the skipped path is returned, in tree order + assert skipped == ["q01.IF"] + # one warning naming the Type, the entry path, the skipped path and + # its Target + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'qubit'" in message + assert "'IF'" in message + assert "'q01.IF'" in message + assert "parameter_manager.q00.IF" in message + # the skipped Lock is untouched and still pulls its own Target + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager.q00.IF", locked=True + ) + assert pm.get("q01.IF") == 1e9 + # the other Instance is locked + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + + +def test_lock_type_parameter_without_skips_warns_nothing(pm, caplog): + put_qubit_instances(pm) + caplog.clear() + + with caplog.at_level(logging.WARNING): + assert pm.lock_type_parameter("qubit", "IF") == [] + + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert warnings_ == [] + + +def test_add_instance_applies_the_existing_type_lock_to_the_new_instance(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + + pm.add_instance("qubit", "q03") + + assert pm.get_lock("q03.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get("q03.IF") == pm.get("_globals.qubit.IF") + + +def test_add_instance_locks_a_kept_parameter_and_keeps_its_own_value_and_unit(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + # q05 carries IF with its own value already, but lacks octave_gain: + # not an Instance yet + pm.add_parameter("q05.IF", initial_value=4e9, unit="Hz") + + pm.add_instance("qubit", "q05") + + # the kept parameter is locked like a created one; its own value and + # unit are untouched (D14 keeps those, D17 adds the Lock) + assert pm.get_lock("q05.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.parameter("q05.IF").own_value() == 4e9 + assert pm.parameter("q05.IF").unit == "Hz" + assert pm.instances_of("qubit") == ["q01", "q02", "q05"] + + +def test_add_instance_relocks_a_follower_that_fell_out_and_came_back(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.unlock("q01.IF") # unlocked individually, remembers the Target + pm.remove_parameter("q01.octave_gain") # q01 stops matching + assert pm.instances_of("qubit") == ["q02"] + + pm.add_instance("qubit", "q01") + + # q01 is a new Instance again: its unlocked Lock remembering the same + # Target is locked again + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get("q01.IF") == pm.get("_globals.qubit.IF") + + +def test_add_instance_skips_a_kept_parameter_locked_to_another_target(pm, caplog): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") + pm.add_parameter("q06.IF", initial_value=4e9, unit="Hz") + pm.lock("q06.IF", "q00.IF") + caplog.clear() + + with caplog.at_level(logging.WARNING): + pm.add_instance("qubit", "q06") + + # the creation itself succeeded and q06 is an Instance + assert pm.instances_of("qubit") == ["q01", "q02", "q06"] + assert pm.get("q06.octave_gain") == 10 + # the kept parameter's Lock on another Target is untouched + assert pm.get_lock("q06.IF") == PMLockBluePrint( + target="parameter_manager.q00.IF", locked=True + ) + assert pm.get("q06.IF") == 1e9 + # one warning names the Type, the entry path, the skipped path and + # its Target + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'qubit'" in message + assert "'IF'" in message + assert "'q06.IF'" in message + assert "parameter_manager.q00.IF" in message + + +def test_after_a_deletion_cleared_the_type_lock_a_new_instance_gets_no_lock( + pm, +): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + + # removing the Globals Target clears the Type Lock (D18, task 3.3) + pm.remove_parameter("_globals.qubit.IF") + assert pm.get_type("qubit").parameters["IF"]["target"] is None + + # no stored Target remains, so a later Instance applies no Lock for + # that entry + pm.add_instance("qubit", "q07") + assert pm.instances_of("qubit") == ["q01", "q02", "q07"] + assert pm.get_lock("q07.IF") is None + assert pm.get("q07.IF") == 5e9 + + # declaring the Type Lock again recreates the Globals Target on demand + # (3.1) and locks every Instance parameter + pm.lock_type_parameter("qubit", "IF") + assert pm.has_param("_globals.qubit.IF") + assert pm.get("_globals.qubit.IF") == 5e9 + for inst in ("q01", "q02", "q07"): + assert pm.get_lock(f"{inst}.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + + +def test_add_instance_skips_a_hand_inserted_stale_target(pm, caplog): + # the public API cannot leave a stored Target pointing nowhere any + # more (remove_parameter clears the Type Lock whose Target it deletes, + # D18), but a registry inserted by hand still can: the application + # must skip that entry instead of raising + put_qubit_instances(pm) + pm._types["qubit"].parameters["IF"].target = "parameter_manager.gone" + caplog.clear() + + with caplog.at_level(logging.WARNING): + pm.add_instance("qubit", "q07") + + # the creation itself still succeeds and q07 is an Instance + assert pm.instances_of("qubit") == ["q01", "q02", "q07"] + assert pm.has_param("q07.IF") + assert pm.get_lock("q07.IF") is None + assert pm.get("q07.IF") == 5e9 + # one warning names the skipped path and the missing stored Target + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'q07.IF'" in message + assert "does not exist" in message + + +def test_add_instance_skips_an_application_the_batch_made_a_cycle(pm, caplog): + # the Targets follow the q05 parameters crosswise: locking q05.a to z1 + # succeeds and only then makes locking q05.b to z2 a cycle — the + # up-front classification ran against the pre-batch state, so the + # application loop must skip instead of raising + pm.add_type("T") + pm.add_type_parameter("T", "a", default=1, unit="V") + pm.add_type_parameter("T", "b", default=2, unit="A") + pm.add_type_parameter("T", "c", default=3, unit="W") + pm.add_parameter("q05.a", initial_value=1, unit="V") + pm.add_parameter("q05.b", initial_value=2, unit="A") + pm.add_parameter("z1", initial_value=0, unit="V") + pm.add_parameter("z2", initial_value=0, unit="A") + pm.lock("z1", "q05.b") + pm.lock("z2", "q05.a") + # no Instances yet: the declarations only store their Targets + assert pm.lock_type_parameter("T", "a", target="z1") == [] + assert pm.lock_type_parameter("T", "b", target="z2") == [] + caplog.clear() + + with caplog.at_level(logging.WARNING): + pm.add_instance("T", "q05") + + # the creation itself succeeded and q05 is an Instance + assert pm.has_param("q05.c") + assert pm.instances_of("T") == ["q05"] + # the first application ran and locked q05.a to z1 + assert pm.get_lock("q05.a") == PMLockBluePrint( + target="parameter_manager.z1", locked=True + ) + # the second application became a cycle only through the first one: + # skipped instead of raised, with one warning naming it and the reason + assert pm.get_lock("q05.b") is None + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'q05.b'" in message + assert "cycle in Lock targets" in message + + +def test_add_instance_skips_a_parameter_that_cannot_carry_a_lock(pm, caplog): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + # q09 carries a plain qcodes Parameter at the entry path — created + # directly on the Parameter Group, bypassing the root — and lacks + # octave_gain: not an Instance yet + parent = pm._get_parent("q09.IF", create_parent=True) + parent._add_own_parameter("IF", set_cmd=None, unit="Hz") + caplog.clear() + + with caplog.at_level(logging.WARNING): + pm.add_instance("qubit", "q09") + + # the creation itself succeeded and q09 is an Instance + assert pm.instances_of("qubit") == ["q01", "q02", "q09"] + assert pm.get("q09.octave_gain") == 10 + # the plain parameter cannot carry a Lock: skipped, with one warning + assert pm.get_lock("q09.IF") is None + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'q09.IF'" in message + assert "cannot carry a Lock" in message + + +def test_add_instance_skips_an_application_that_would_close_a_cycle(pm, caplog): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + # q10 carries IF with its own value and lacks octave_gain; the Globals + # Target follows q10.IF — a Globals parameter is an ordinary parameter + # and may itself carry a Lock (D18) + pm.add_parameter("q10.IF", initial_value=4e9, unit="Hz") + pm.lock("_globals.qubit.IF", "q10.IF") + caplog.clear() + + with caplog.at_level(logging.WARNING): + pm.add_instance("qubit", "q10") + + # the creation itself succeeded and q10 is an Instance + assert pm.instances_of("qubit") == ["q01", "q02", "q10"] + # locking q10.IF to the Globals Target would close the cycle + # _globals.qubit.IF -> q10.IF -> _globals.qubit.IF: skipped + assert pm.get_lock("q10.IF") is None + assert pm.get("q10.IF") == 4e9 + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + message = warnings_[0].getMessage() + assert "'q10.IF'" in message + assert "cycle in Lock targets" in message + + +def test_the_creation_methods_leave_an_existing_instance_lock_state_untouched(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.unlock("q01.IF") # unlocked individually + + pm.add_instance("qubit", "q08") + + # q01 was an Instance before the edit: its unlocked Lock stays + # unlocked; only lock_type_parameter re-applies to everyone + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=False + ) + # the new Instance is locked + assert pm.get_lock("q08.IF").locked is True + + +def test_add_type_parameter_applies_no_type_locks(pm): + # under the edit loops the parameters are created only under Instances + # found before the edit, so no submodule becomes a new Instance and no + # Type Lock is applied: the fresh entry carries no Target, and the + # Instances that existed keep their Lock state + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.unlock("q01.IF") # an individually unlocked Follower stays unlocked + locks_before = pm.list_locks() + + pm.add_type_parameter("qubit", "extra", default=1, unit="V") + + # the created parameters carry no Lock + assert pm.get_lock("q01.extra") is None + assert pm.get_lock("q02.extra") is None + assert pm.list_locks() == locks_before + + # the same through a nesting Type: readout's fresh entry window is + # created under qubit's Instance, with no Lock + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_nested_type("qubit", "readout", "readout") + pm.lock_type_parameter("readout", "IF") + locks_before = pm.list_locks() + + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + + assert pm.get_lock("q01.readout.window") is None + assert pm.list_locks() == locks_before + + +def test_add_nested_type_applies_the_nested_type_lock_under_the_submodule(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + pm.lock_type_parameter("readout", "IF") + + pm.add_nested_type("qubit", "readout", "readout") + + # the created readout.IF is locked to readout's Globals Target + assert pm.get_lock("q01.readout.IF") == PMLockBluePrint( + target="parameter_manager._globals.readout.IF", locked=True + ) + assert pm.get("q01.readout.IF") == pm.get("_globals.readout.IF") + + +def test_add_nested_type_locks_a_pre_existing_parameter_when_it_completes(pm): + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + # q01.readout carries only IF: not an Instance of readout yet, so the + # declaration locks nobody + pm.add_parameter("q01.readout.IF", initial_value=10e6, unit="Hz") + pm.lock_type_parameter("readout", "IF") + assert pm.get_lock("q01.readout.IF") is None + + pm.add_nested_type("qubit", "readout", "readout") + + # the edit creates the missing window, completing q01.readout into a + # new Instance of readout: its PRE-EXISTING IF gets the Type Lock + assert pm.get_lock("q01.readout.IF") == PMLockBluePrint( + target="parameter_manager._globals.readout.IF", locked=True + ) + # the entry without a Type Lock stays unlocked + assert pm.get_lock("q01.readout.window") is None + + +def test_add_nested_type_locks_a_pre_existing_parameter_through_two_levels(pm): + # the union walk reaches the inner position through two nesting levels: + # super nests qubit at q, qubit nests readout at readout + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_type("super") + pm.add_type_parameter("super", "top", default=1, unit="") + pm.add_nested_type("super", "q", "qubit") + pm.add_parameter("s01.top", initial_value=1, unit="") + pm.add_parameter("s01.q.octave_gain", initial_value=10, unit="dB") + # the inner position carries readout.IF only: not a readout-Instance + # yet, so the declaration locks nobody + pm.add_parameter("s01.q.readout.IF", initial_value=10e6, unit="Hz") + assert pm.instances_of("readout") == [] + assert pm.lock_type_parameter("readout", "IF") == [] + + pm.add_nested_type("qubit", "readout", "readout") + + # the edit created the missing window, completing s01.q.readout into a + # new Instance of readout: its PRE-EXISTING IF gets the Type Lock + assert pm.instances_of("readout") == ["s01.q.readout"] + assert pm.get_lock("s01.q.readout.IF") == PMLockBluePrint( + target="parameter_manager._globals.readout.IF", locked=True + ) + # the entry without a Type Lock stays unlocked + assert pm.get_lock("s01.q.readout.window") is None + + +def test_unlock_type_parameter_leaves_every_lock_in_place(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + locks_before = pm.list_locks() + + pm.unlock_type_parameter("qubit", "IF") + + # only the Type Lock goes: the entry's Target is cleared ... + assert pm.get_type("qubit").parameters["IF"]["target"] is None + # ... and every Lock stays, locked as it was + assert pm.list_locks() == locks_before + assert pm.get("q01.IF") == pm.get("_globals.qubit.IF") + with pytest.raises( + ValueError, + match=re.escape( + "parameter_manager.q01.IF is locked to parameter_manager._globals.qubit.IF" + ), + ): + pm.set("q01.IF", 1) + + +def test_unlock_type_parameter_without_a_type_lock_is_a_logged_no_op(pm, caplog): + put_qubit_instances(pm) + received = [] + pm.add_broadcast_sink(received.append) + caplog.clear() + + with caplog.at_level(logging.INFO): + pm.unlock_type_parameter("qubit", "IF") + + assert pm.get_type("qubit").parameters["IF"]["target"] is None + # the no-op emits nothing + assert received == [] + records = [ + r + for r in caplog.records + if r.levelno == logging.INFO and r.name == "instrumentserver.params" + ] + assert len(records) == 1 + assert "carries no Type Lock" in records[0].getMessage() + + +def test_an_instance_falling_out_keeps_its_locks(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + + pm.remove_parameter("q01.octave_gain") # q01 stops matching + + assert pm.instances_of("qubit") == ["q02"] + # the Lock stays on the parameter that fell out + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get("q01.IF") == pm.get("_globals.qubit.IF") + + +def test_re_declaring_the_type_lock_re_applies_it(pm, caplog): + put_qubit_instances(pm) + pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") + pm.lock_type_parameter("qubit", "IF") + # q01 is re-targeted to another Target, q02 is unlocked individually + pm.lock("q01.IF", "q00.IF") + pm.unlock("q02.IF") + caplog.clear() + + with caplog.at_level(logging.WARNING): + skipped = pm.lock_type_parameter("qubit", "IF") + + # the Follower on another Target is skipped again, with the warning + assert skipped == ["q01.IF"] + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager.q00.IF", locked=True + ) + # the individually unlocked Follower is locked again ("lock all") + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get("q02.IF") == pm.get("_globals.qubit.IF") + warnings_ = [ + r + for r in caplog.records + if r.levelno == logging.WARNING and r.name == "instrumentserver.params" + ] + assert len(warnings_) == 1 + assert "'q01.IF'" in warnings_[0].getMessage() + + +def test_re_declaring_with_a_different_target_skips_the_old_followers(pm, caplog): + put_qubit_instances(pm) + pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") + pm.lock_type_parameter("qubit", "IF") # everyone locked to the Globals + pm.remove_lock("q02.IF") # no Lock at all + caplog.clear() + + with caplog.at_level(logging.WARNING): + skipped = pm.lock_type_parameter("qubit", "IF", target="q00.IF") + + # the Follower still locked to the old Target is skipped + assert skipped == ["q01.IF"] + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + # the parameter without a Lock is locked to the new Target + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager.q00.IF", locked=True + ) + assert pm.get("q02.IF") == 1e9 + # the entry carries the new Target + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "parameter_manager.q00.IF" + ) + + +def test_lock_type_parameter_with_an_explicit_ordinary_target(pm): + put_qubit_instances(pm) + pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") + + skipped = pm.lock_type_parameter("qubit", "IF", target="q00.IF") + + assert skipped == [] + # no Globals parameter is created for an explicit Target + assert not pm.has_param("_globals.qubit.IF") + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "parameter_manager.q00.IF" + ) + for inst in ("q01", "q02"): + assert pm.get_lock(f"{inst}.IF") == PMLockBluePrint( + target="parameter_manager.q00.IF", locked=True + ) + pm.set("q00.IF", 2e9) + assert pm.get("q01.IF") == 2e9 + + +def test_lock_type_parameter_refuses_an_unknown_type(pm): + put_qubit_instances(pm) + before = lock_state(pm) + + with pytest.raises(ValueError, match="no Type named 'nope' exists"): + pm.lock_type_parameter("nope", "IF") + with pytest.raises(ValueError, match="no Type named 'nope' exists"): + pm.unlock_type_parameter("nope", "IF") + + assert lock_state(pm) == before + + +def test_lock_type_parameter_refuses_a_path_that_is_not_an_own_entry(pm): + put_three_tier_registry(pm) + put_three_tier_tree(pm) + before = lock_state(pm) + + with pytest.raises( + ValueError, + match=re.escape( + "parameter path 'readout.IF' is not an entry of Type 'qubit' " + "itself: it is only in the effective set through the entry of " + "Type 'readout'" + ), + ): + pm.lock_type_parameter("qubit", "readout.IF") + with pytest.raises( + ValueError, + match=re.escape("parameter path 'nope' is not an entry of Type 'qubit'"), + ): + pm.lock_type_parameter("qubit", "nope") + # unlock_type_parameter refuses the same way + with pytest.raises( + ValueError, + match=re.escape("'readout.IF' is not an entry of Type 'qubit' itself"), + ): + pm.unlock_type_parameter("qubit", "readout.IF") + + assert lock_state(pm) == before + + +def test_lock_type_parameter_refuses_a_missing_explicit_target(pm): + put_qubit_instances(pm) + before = lock_state(pm) + + with pytest.raises(ValueError, match="Parameter 'nope.IF' does not exist"): + pm.lock_type_parameter("qubit", "IF", target="nope.IF") + + # nothing was created and nobody was locked + assert lock_state(pm) == before + assert not pm.has_param("_globals.qubit.IF") + + +def test_lock_type_parameter_refuses_a_self_lock_naming_the_path(pm): + put_qubit_instances(pm) + before = lock_state(pm) + + # the explicit Target is the entry's own parameter in one of the + # Instances: its own Instance is a self-lock + with pytest.raises( + ValueError, + match=re.escape("cannot lock parameter_manager.q01.IF to itself"), + ): + pm.lock_type_parameter("qubit", "IF", target="q01.IF") + + assert lock_state(pm) == before + assert not pm.has_param("_globals.qubit.IF") + + +def test_lock_type_parameter_refuses_a_cycle_naming_the_path(pm): + put_qubit_instances(pm) + pm.add_parameter("q00.IF", initial_value=1e9, unit="Hz") + # the Target is a Follower of the Instance parameter q01.IF + pm.lock("q00.IF", "q01.IF") + before = lock_state(pm) + + with pytest.raises( + ValueError, + match=re.escape( + "cannot lock parameter_manager.q01.IF to " + "parameter_manager.q00.IF: cycle in Lock targets: " + "parameter_manager.q00.IF -> parameter_manager.q01.IF" + ), + ): + pm.lock_type_parameter("qubit", "IF", target="q00.IF") + + assert lock_state(pm) == before + + +def test_lock_type_parameter_names_every_offending_path(pm): + # both Instances carry a plain qcodes Parameter at the entry path: + # neither can carry a Lock, and one error names both (rule 3) + put_qubit_instances(pm) + pm.remove_parameter("q01.IF") + pm.remove_parameter("q02.IF") + pm.q01._add_own_parameter("IF", set_cmd=None, unit="Hz") + pm.q02._add_own_parameter("IF", set_cmd=None, unit="Hz") + assert pm.instances_of("qubit") == ["q01", "q02"] + before = lock_state(pm) + + with pytest.raises(ValueError) as excinfo: + pm.lock_type_parameter("qubit", "IF") + + message = str(excinfo.value) + assert "parameter_manager.q01.IF cannot carry a Lock" in message + assert "parameter_manager.q02.IF cannot carry a Lock" in message + assert lock_state(pm) == before + assert not pm.has_param("_globals.qubit.IF") + + +# --------------------------------------------------------------------------- +# Deletion interplay (plan task 3.3, D17/D18) +# +# Removing a parameter that is the stored Target of Type Locks — a Globals +# parameter, or any parameter an explicit Type Lock points at — drops +# every Lock pointing at it and clears those Type Locks (the entries' +# Targets go back to None). remove_type takes the Type and its Type Locks +# but leaves the Globals parameters and every Instance Lock alone; +# remove_type_parameter takes the entry's Type Lock with it under the +# same reservations. After a deletion cleared a Type Lock, a later +# add_instance applies no Lock for that entry and a re-declared +# lock_type_parameter recreates the Globals Target on demand. +# --------------------------------------------------------------------------- + + +def test_removing_the_globals_target_drops_every_lock_and_clears_the_type_lock( + pm, +): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.set("_globals.qubit.IF", 7e9) + + pm.remove_parameter("_globals.qubit.IF") + + # every Follower is unlocked and answers get with its own value again + assert pm.get_lock("q01.IF") is None + assert pm.get_lock("q02.IF") is None + assert pm.get("q01.IF") == 5e9 + assert pm.get("q02.IF") == 6e9 + # the Type Lock is cleared + assert pm.get_type("qubit").parameters["IF"]["target"] is None + + +def test_removing_a_shared_target_clears_the_type_locks_of_every_type(pm): + # two Types declaring their Type Lock on one explicit Target; the + # Target is a root parameter, which is never an Instance (D12), and + # the two Instances differ in unit so each matches only its own Type + pm.add_parameter("shared_IF", initial_value=9e9, unit="Hz") + pm.add_parameter("q01.IF", initial_value=1e9, unit="Hz") + pm.add_parameter("q02.IF", initial_value=2e9, unit="V") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type("qubit2") + pm.add_type_parameter("qubit2", "IF", default=6e9, unit="V") + pm.lock_type_parameter("qubit", "IF", target="shared_IF") + pm.lock_type_parameter("qubit2", "IF", target="shared_IF") + + pm.remove_parameter("shared_IF") + + assert pm.get_lock("q01.IF") is None + assert pm.get_lock("q02.IF") is None + assert pm.get_type("qubit").parameters["IF"]["target"] is None + assert pm.get_type("qubit2").parameters["IF"]["target"] is None + + +def test_removing_an_ordinary_explicit_target_does_the_same(pm): + put_qubit_instances(pm) + pm.add_parameter("shared.IF", initial_value=9e9, unit="Hz") + pm.lock_type_parameter("qubit", "IF", target="shared.IF") + + pm.remove_parameter("shared.IF") + + assert pm.get_lock("q01.IF") is None + assert pm.get_lock("q02.IF") is None + assert pm.get("q01.IF") == 5e9 + assert pm.get_type("qubit").parameters["IF"]["target"] is None + + +def test_removing_a_target_of_ordinary_locks_only_touches_no_type_lock(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.add_parameter("q01Data.IF", initial_value=1e9, unit="Hz") + pm.lock("q02.octave_gain", "q01Data.IF") + + pm.remove_parameter("q01Data.IF") + + # the ordinary Lock is dropped, the Type Lock and its Followers are + # untouched + assert pm.get_lock("q02.octave_gain") is None + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "parameter_manager._globals.qubit.IF" + ) + + +def test_removing_a_target_of_ordinary_and_type_locks_cleans_both_up(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + # an ordinary Follower of the Globals Target on top of the Type Lock + # Followers + pm.add_parameter("q01Data.IF", initial_value=1e9, unit="Hz") + pm.lock("q01Data.IF", "_globals.qubit.IF") + + pm.remove_parameter("_globals.qubit.IF") + + assert pm.get_lock("q01Data.IF") is None + assert pm.get_lock("q01.IF") is None + assert pm.get_lock("q02.IF") is None + assert pm.get_type("qubit").parameters["IF"]["target"] is None + + +def test_removing_a_follower_touches_no_type_lock(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + + pm.remove_parameter("q01.IF") # a Follower, not the Target + + # q01 stops matching and its Lock is gone with the parameter, but the + # Type Lock and the other Follower are untouched + assert pm.get_type("qubit").parameters["IF"]["target"] == ( + "parameter_manager._globals.qubit.IF" + ) + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.instances_of("qubit") == ["q02"] + + +def test_the_removal_refusal_leaves_locks_and_types_untouched(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + before = lock_state(pm) + + with pytest.raises(KeyError): + pm.remove_parameter("q01.nope") + with pytest.raises(ValueError): + pm.remove_parameter("nope.IF") + + assert lock_state(pm) == before + assert pm.has_param("_globals.qubit.IF") + + +def test_remove_type_leaves_the_globals_parameters_and_instance_locks(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.unlock("q02.IF") # one Follower unlocked individually + + pm.remove_type("qubit") + + # the Type and with it its Type Locks are gone, but the Globals + # parameters stay, ordinary as ever (D13, D18) + assert pm.list_types() == [] + assert pm.has_param("_globals.qubit.IF") + assert pm.get("_globals.qubit.IF") == 5e9 + assert pm.parameter("_globals.qubit.IF").unit == "Hz" + # every Instance Lock stays, locked or unlocked + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=False + ) + assert pm.get("q01.IF") == 5e9 + assert pm.get("q02.IF") == 6e9 + + +def test_remove_type_parameter_takes_the_type_lock_with_it(pm): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + + pm.remove_type_parameter("qubit", "IF") + + # the entry and with it the Type Lock are gone (D13) + assert pm.get_type("qubit").parameters == { + "octave_gain": {"default": 10, "unit": "dB", "target": None}, + } + # the Locks and the Globals parameter stay + assert pm.get_lock("q01.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.get_lock("q02.IF") == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert pm.has_param("_globals.qubit.IF") + # the Instances keep the parameter and still carry the remaining + # entry, so they keep matching (D13, D1: extra parameters don't + # matter, and octave_gain is still required and present) + assert pm.has_param("q01.IF") + assert pm.instances_of("qubit") == ["q01", "q02"] + + +def test_remove_all_parameters_clears_the_type_locks_whose_targets_it_removes( + pm, +): + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + + pm.remove_all_parameters() + + # remove_all_parameters routes through remove_parameter, so removing + # the Globals Target clears the Type Lock as a consequence (D18); the + # Type definitions themselves stay (task 4.3 clears them when + # switching profiles) + assert pm.list() == [] + assert pm.list_types() == ["qubit"] + assert pm.get_type("qubit").parameters["IF"]["target"] is None + + +# --------------------------------------------------------------------------- +# pm-type-update and side-effect creation Broadcasts (plan task 2.5, D22) +# +# One pm-type-update per affected Type — the edited Type first, then every +# Type nesting it, whose effective parameter set the edit changed — +# carrying that Type's fresh PMTypeBluePrint; remove_type emits exactly +# one with a None payload. The parameters a Type edit or add_instance +# creates as side effects are re-emitted as one parameter-creation each, +# in creation order, before the Type updates. Read-only queries, refused +# calls and kept parameters emit nothing. +# --------------------------------------------------------------------------- + + +@pytest.fixture +def pm_with_sink(pm): + """The Type API fixture with a Broadcast sink attached, recording + every Broadcast the Parameter Manager emits.""" + received = [] + pm.add_broadcast_sink(received.append) + return pm, received + + +def put_nested_instance(pm): + """``readout`` (entry IF) nested in ``qubit``, with the Instance q01 + carrying the whole shape: an edit to readout reaches qubit's effective + parameter set too, so both Types are affected.""" + pm.add_type("readout") + pm.add_type_parameter("readout", "IF", default=10e6, unit="Hz") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_nested_type("qubit", "readout", "readout") + pm.add_parameter("q01.readout.IF", initial_value=10e6, unit="Hz") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + + +def test_add_type_emits_one_pm_type_update_with_the_fresh_blueprint(pm_with_sink): + pm, received = pm_with_sink + + pm.add_type("qubit") + + assert len(received) == 1 + bp = received[0] + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == "parameter_manager.qubit" + assert bp.action == PM_TYPE_UPDATE + assert isinstance(bp.value, PMTypeBluePrint) + assert bp.value == PMTypeBluePrint( + name="qubit", parameters={}, nested={}, effective={} + ) + + +def test_remove_type_emits_one_none_payload(pm_with_sink): + pm, received = pm_with_sink + pm.add_type("readout") + pm.add_type("qubit") + pm.add_nested_type("qubit", "readout", "readout") + received.clear() + + pm.remove_type("qubit") + + # qubit nests readout, not the other way round: removing it affects no + # other Type, so exactly one Broadcast with a None payload goes out + assert len(received) == 1 + bp = received[0] + assert bp.name == "parameter_manager.qubit" + assert bp.action == PM_TYPE_UPDATE + assert bp.value is None + + +def test_add_type_parameter_emits_the_creation_then_the_type_updates(pm_with_sink): + pm, received = pm_with_sink + put_nested_instance(pm) + received.clear() + + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + + # the side-effect creation first, then one pm-type-update per affected + # Type: the edited readout first, then qubit, whose effective set the + # new entry extends + assert len(received) == 3 + creation, readout_update, qubit_update = received + assert creation.name == "parameter_manager.q01.readout.window" + assert creation.action == PARAMETER_CREATION + assert creation.value == 2e-6 + assert creation.unit == "s" + assert readout_update.name == "parameter_manager.readout" + assert readout_update.action == PM_TYPE_UPDATE + assert isinstance(readout_update.value, PMTypeBluePrint) + assert readout_update.value.parameters["window"] == { + "default": 2e-6, + "unit": "s", + "target": None, + } + assert qubit_update.name == "parameter_manager.qubit" + assert qubit_update.action == PM_TYPE_UPDATE + assert qubit_update.value.effective["readout.window"] == { + "unit": "s", + "from_type": "readout", + } + + +def test_pm_type_update_reaches_every_type_of_a_three_tier_nesting_chain( + pm_with_sink, +): + # the walk must follow the nesting chain transitively: pulse_window is + # nested in readout, which is nested in qubit, so an edit to + # pulse_window affects all three Types — a walk stopping at the direct + # nesters would miss qubit + pm, received = pm_with_sink + put_three_tier_registry(pm) + received.clear() + + pm.add_type_parameter("pulse_window", "amp", default=1, unit="V") + + # no Instances exist, so no parameter-creation goes out: exactly one + # pm-type-update per affected Type, the edited pulse_window first, + # then outwards along the chain + assert len(received) == 3 + pulse_update, readout_update, qubit_update = received + assert [bp.action for bp in received] == [PM_TYPE_UPDATE] * 3 + assert isinstance(pulse_update.value, PMTypeBluePrint) + assert pulse_update.name == "parameter_manager.pulse_window" + assert pulse_update.value.parameters["amp"] == { + "default": 1, + "unit": "V", + "target": None, + } + assert isinstance(readout_update.value, PMTypeBluePrint) + assert readout_update.name == "parameter_manager.readout" + assert readout_update.value.effective["pw.amp"] == { + "unit": "V", + "from_type": "pulse_window", + } + assert isinstance(qubit_update.value, PMTypeBluePrint) + assert qubit_update.name == "parameter_manager.qubit" + assert qubit_update.value.effective["readout.pw.amp"] == { + "unit": "V", + "from_type": "pulse_window", + } + + +def test_add_type_parameter_emits_no_creation_for_kept_parameters(pm_with_sink): + # q01.readout.window exists already: the edit writes the entry into the + # registry only, and the kept parameter emits no parameter-creation + # (D22); readout's own Instance target and qubit's prefixed target are + # the same path, so nothing is created at all + pm, received = pm_with_sink + put_nested_instance(pm) + pm.add_parameter("q01.readout.window", initial_value=3e-6, unit="s") + received.clear() + + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + + assert [bp.action for bp in received] == [PM_TYPE_UPDATE] * 2 + readout_update, qubit_update = received + assert readout_update.name == "parameter_manager.readout" + assert qubit_update.name == "parameter_manager.qubit" + # the kept parameter kept its own value (D13) + assert pm.get("q01.readout.window") == 3e-6 + + +def test_remove_type_parameter_emits_updates_for_the_edited_and_nesting_types( + pm_with_sink, +): + pm, received = pm_with_sink + put_nested_instance(pm) + pm.add_type_parameter("readout", "window", default=2e-6, unit="s") + received.clear() + + pm.remove_type_parameter("readout", "window") + + # no parameter is removed (D13), so no creations: one pm-type-update + # per affected Type, the edited readout first + assert len(received) == 2 + readout_update, qubit_update = received + assert readout_update.name == "parameter_manager.readout" + assert readout_update.action == PM_TYPE_UPDATE + assert "window" not in readout_update.value.parameters + assert qubit_update.name == "parameter_manager.qubit" + assert qubit_update.action == PM_TYPE_UPDATE + assert "readout.window" not in qubit_update.value.effective + + +def test_set_type_parameter_default_emits_one_update_for_the_edited_type( + pm_with_sink, +): + pm, received = pm_with_sink + put_nested_instance(pm) + received.clear() + + pm.set_type_parameter_default("readout", "IF", 20e6) + + # only the edited Type is named: a nesting Type's blueprint is + # unchanged, since the effective parameter set carries units and + # defining Types, not defaults + assert len(received) == 1 + update = received[0] + assert update.name == "parameter_manager.readout" + assert update.action == PM_TYPE_UPDATE + assert update.value.parameters["IF"]["default"] == 20e6 + + +def test_set_type_parameter_unit_emits_updates_for_every_affected_type(pm_with_sink): + pm, received = pm_with_sink + put_nested_instance(pm) + received.clear() + + pm.set_type_parameter_unit("readout", "IF", "V") + + # the unit reaches every Instance and the effective parameter set of + # every Type nesting the edited one, so both Types are named + assert len(received) == 2 + readout_update, qubit_update = received + assert readout_update.name == "parameter_manager.readout" + assert readout_update.value.effective["IF"]["unit"] == "V" + assert qubit_update.name == "parameter_manager.qubit" + assert qubit_update.value.effective["readout.IF"]["unit"] == "V" + # the propagation itself happened (D13) + assert pm.parameter("q01.readout.IF").unit == "V" + + +def test_add_nested_type_emits_the_creation_then_the_type_updates(pm_with_sink): + pm, received = pm_with_sink + put_nested_instance(pm) + pm.add_type("pulse_window") + pm.add_type_parameter("pulse_window", "duration", default=None, unit="s") + received.clear() + + pm.add_nested_type("readout", "pw", "pulse_window") + + assert len(received) == 3 + creation, readout_update, qubit_update = received + assert creation.name == "parameter_manager.q01.readout.pw.duration" + assert creation.action == PARAMETER_CREATION + assert creation.value is None + assert creation.unit == "s" + assert readout_update.name == "parameter_manager.readout" + assert readout_update.action == PM_TYPE_UPDATE + assert readout_update.value.nested == {"pw": "pulse_window"} + assert qubit_update.name == "parameter_manager.qubit" + assert qubit_update.action == PM_TYPE_UPDATE + assert qubit_update.value.effective["readout.pw.duration"] == { + "unit": "s", + "from_type": "pulse_window", + } + + +def test_add_nested_type_emits_no_creation_for_kept_parameters(pm_with_sink): + # the nested entry's parameter exists already at the target path: the + # edit writes the Nested Type into the registry only, and the kept + # parameter emits no parameter-creation (D22) + pm, received = pm_with_sink + put_nested_instance(pm) + pm.add_type("pulse_window") + pm.add_type_parameter("pulse_window", "duration", default=None, unit="s") + pm.add_parameter("q01.readout.pw.duration", initial_value=500e-9, unit="s") + received.clear() + + pm.add_nested_type("readout", "pw", "pulse_window") + + assert [bp.action for bp in received] == [PM_TYPE_UPDATE] * 2 + readout_update, qubit_update = received + assert readout_update.name == "parameter_manager.readout" + assert qubit_update.name == "parameter_manager.qubit" + # the kept parameter kept its own value (D13) + assert pm.get("q01.readout.pw.duration") == 500e-9 + + +def test_remove_nested_type_emits_updates_for_the_edited_and_nesting_types( + pm_with_sink, +): + pm, received = pm_with_sink + put_nested_instance(pm) + pm.add_type("pulse_window") + pm.add_type_parameter("pulse_window", "duration", default=None, unit="s") + pm.add_nested_type("readout", "pw", "pulse_window") + received.clear() + + pm.remove_nested_type("readout", "pw") + + assert len(received) == 2 + readout_update, qubit_update = received + assert readout_update.name == "parameter_manager.readout" + assert readout_update.action == PM_TYPE_UPDATE + assert readout_update.value.nested == {} + assert qubit_update.name == "parameter_manager.qubit" + assert qubit_update.action == PM_TYPE_UPDATE + assert "readout.pw.duration" not in qubit_update.value.effective + + +def test_add_instance_emits_one_creation_per_created_parameter(pm_with_sink): + pm, received = pm_with_sink + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + received.clear() + + pm.add_instance("qubit", "q01") + + # one parameter-creation per created parameter, in creation order; + # add_instance edits no Type, so no pm-type-update goes out (D22) + assert len(received) == 2 + first, second = received + assert first.name == "parameter_manager.q01.IF" + assert first.action == PARAMETER_CREATION + assert first.value == 5e9 + assert first.unit == "Hz" + assert second.name == "parameter_manager.q01.octave_gain" + assert second.action == PARAMETER_CREATION + assert second.value == 10 + assert second.unit == "dB" + + +def test_add_instance_emits_nothing_for_kept_parameters(pm_with_sink): + pm, received = pm_with_sink + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.add_parameter("q01.IF", initial_value=6e9, unit="Hz") + received.clear() + + pm.add_instance("qubit", "q01") + + # the kept q01.IF emits nothing; only the created octave_gain does + assert len(received) == 1 + assert received[0].name == "parameter_manager.q01.octave_gain" + assert received[0].action == PARAMETER_CREATION + + +def test_add_instance_of_an_empty_type_emits_nothing(pm_with_sink): + pm, received = pm_with_sink + pm.add_type("empty") + received.clear() + + pm.add_instance("empty", "q01") + + # an empty Type creates nothing, so nothing is broadcast + assert received == [] + assert pm.list() == [] + + +def test_read_only_type_queries_emit_nothing(pm_with_sink): + pm, received = pm_with_sink + put_nested_instance(pm) + received.clear() + + assert pm.list_types() == ["readout", "qubit"] + assert isinstance(pm.get_type("qubit"), PMTypeBluePrint) + assert pm.instances_of("qubit") == ["q01"] + assert pm.types_of("q01.octave_gain") == ["qubit"] + + assert received == [] + + +def test_failed_type_validations_emit_nothing(pm_with_sink): + pm, received = pm_with_sink + put_nested_instance(pm) + received.clear() + + # definitions + with pytest.raises(ValueError): + pm.add_type("_globals") + with pytest.raises(ValueError): + pm.add_type("readout") # duplicate name + with pytest.raises(ValueError): + pm.remove_type("nope") + with pytest.raises(ValueError): + pm.remove_type("readout") # nested in qubit + # edits + with pytest.raises(ValueError): + pm.add_type_parameter("readout", "IF") # already in the effective set + with pytest.raises(ValueError): + pm.add_type_parameter("nope", "x") + with pytest.raises(ValueError): + pm.remove_type_parameter("readout", "nope") + with pytest.raises(ValueError): + pm.set_type_parameter_default("readout", "nope", 1) + with pytest.raises(ValueError): + pm.set_type_parameter_unit("readout", "nope", "V") + # nesting + with pytest.raises(ValueError): + pm.add_nested_type("readout", "self", "readout") + with pytest.raises(ValueError): + pm.add_nested_type("qubit", "readout", "readout") # occupied submodule + with pytest.raises(ValueError): + pm.add_nested_type("qubit", "_globals", "readout") + with pytest.raises(ValueError): + pm.remove_nested_type("readout", "pw") # no Nested Type there + # instances + with pytest.raises(ValueError): + pm.add_instance("nope", "q09") + with pytest.raises(ValueError): + pm.add_instance("qubit", "_globals") + with pytest.raises(ValueError): + pm.add_instance("qubit", "") + pm.add_parameter("q09.octave_gain", initial_value=1, unit="V") + with pytest.raises(ValueError): + pm.add_instance("qubit", "q09") # unit conflict on q09.octave_gain + pm.add_parameter("q10.octave_gain.sub", initial_value=0, unit="s") + with pytest.raises(ValueError): + pm.add_instance("qubit", "q10") # target q10.octave_gain is a Parameter Group + + assert received == [] + + +def test_type_broadcast_payloads_are_snapshots_of_their_time(pm_with_sink): + # every pm-type-update carries a blueprint built at emit time: a sink + # that keeps payloads must not see an earlier Type grow when entries + # are added later + pm, received = pm_with_sink + + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + + assert len(received) == 3 + first, second, third = (bp.value for bp in received) + assert isinstance(first, PMTypeBluePrint) + assert first.parameters == {} + assert list(second.parameters) == ["IF"] + assert list(third.parameters) == ["IF", "octave_gain"] + + +# --------------------------------------------------------------------------- +# Type Lock Broadcasts (plan task 3.2, D10/D17/D22) +# +# A declaration emits the Globals parameter-creation (when created), then +# one pm-lock-update per Lock it created or relocked — nothing for skipped +# or already-locked parameters — then one pm-type-update for the edited +# Type. A removal emits exactly one pm-type-update; the no-op removal +# emits nothing. add_instance with an existing Type Lock emits its +# parameter-creations first, then the pm-lock-updates. A refused call +# emits nothing. +# --------------------------------------------------------------------------- + + +def test_lock_type_parameter_emits_creation_lock_updates_then_type_update( + pm_with_sink, +): + pm, received = pm_with_sink + put_qubit_instances(pm) + received.clear() + + pm.lock_type_parameter("qubit", "IF") + + assert len(received) == 4 + creation, first_lock, second_lock, type_update = received + assert creation.name == "parameter_manager._globals.qubit.IF" + assert creation.action == PARAMETER_CREATION + assert creation.value == 5e9 + assert creation.unit == "Hz" + assert first_lock.name == "parameter_manager.q01.IF" + assert first_lock.action == PM_LOCK_UPDATE + assert first_lock.value == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert second_lock.name == "parameter_manager.q02.IF" + assert second_lock.action == PM_LOCK_UPDATE + assert second_lock.value == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + assert type_update.name == "parameter_manager.qubit" + assert type_update.action == PM_TYPE_UPDATE + assert type_update.value.parameters["IF"]["target"] == ( + "parameter_manager._globals.qubit.IF" + ) + + +def test_re_declaring_an_all_locked_type_lock_emits_only_the_type_update( + pm_with_sink, +): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + pm.lock_type_parameter("qubit", "IF") + + # every Instance parameter is already locked to the same Target: no + # pm-lock-update, and the Globals parameter exists already + assert len(received) == 1 + assert received[0].name == "parameter_manager.qubit" + assert received[0].action == PM_TYPE_UPDATE + + +def test_unlock_type_parameter_emits_exactly_one_pm_type_update(pm_with_sink): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + pm.unlock_type_parameter("qubit", "IF") + + # one pm-type-update and no pm-lock-update: the Locks stay + assert len(received) == 1 + update = received[0] + assert update.name == "parameter_manager.qubit" + assert update.action == PM_TYPE_UPDATE + assert update.value.parameters["IF"]["target"] is None + + received.clear() + pm.unlock_type_parameter("qubit", "IF") # no Type Lock any more: no-op + assert received == [] + + +def test_add_instance_with_a_type_lock_emits_creations_then_lock_updates( + pm_with_sink, +): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + pm.add_instance("qubit", "q03") + + # the parameter-creations first, then the pm-lock-update of the + # applied Type Lock; add_instance edits no Type, so no pm-type-update + assert len(received) == 3 + first, second, lock_update = received + assert first.name == "parameter_manager.q03.IF" + assert first.action == PARAMETER_CREATION + assert second.name == "parameter_manager.q03.octave_gain" + assert second.action == PARAMETER_CREATION + assert lock_update.name == "parameter_manager.q03.IF" + assert lock_update.action == PM_LOCK_UPDATE + assert lock_update.value == PMLockBluePrint( + target="parameter_manager._globals.qubit.IF", locked=True + ) + + +def test_failed_type_lock_calls_emit_nothing(pm_with_sink): + pm, received = pm_with_sink + put_qubit_instances(pm) + received.clear() + + with pytest.raises(ValueError): + pm.lock_type_parameter("nope", "IF") + with pytest.raises(ValueError): + pm.lock_type_parameter("qubit", "nope") + with pytest.raises(ValueError): + pm.lock_type_parameter("qubit", "IF", target="nope.IF") + with pytest.raises(ValueError): + pm.lock_type_parameter("qubit", "IF", target="q01.IF") # self-lock + with pytest.raises(ValueError): + pm.unlock_type_parameter("qubit", "nope") + + assert received == [] + assert not pm.has_param("_globals.qubit.IF") + assert pm.list_locks() == {} + + +# --------------------------------------------------------------------------- +# Deletion Broadcasts (plan task 3.3, D10/D18/D22) +# +# Removing a Type Lock Target emits one pm-lock-update with None per +# dropped Follower (tree order), then one pm-type-update per affected +# Type (registry order, Target cleared) — and nothing else; the deletion +# itself is announced by the Server only. remove_type still emits exactly +# one pm-type-update with None and no pm-lock-update. A refused removal +# emits nothing. +# --------------------------------------------------------------------------- + + +def test_removing_a_type_lock_target_emits_lock_updates_then_type_updates( + pm_with_sink, +): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + pm.remove_parameter("_globals.qubit.IF") + + # two pm-lock-updates with None, one per dropped Follower in tree + # order, then one pm-type-update for the affected Type — nothing else + assert len(received) == 3 + first, second, type_update = received + assert first.name == "parameter_manager.q01.IF" + assert first.action == PM_LOCK_UPDATE + assert first.value is None + assert second.name == "parameter_manager.q02.IF" + assert second.action == PM_LOCK_UPDATE + assert second.value is None + assert type_update.name == "parameter_manager.qubit" + assert type_update.action == PM_TYPE_UPDATE + assert isinstance(type_update.value, PMTypeBluePrint) + assert type_update.value.parameters["IF"]["target"] is None + + +def test_removing_a_shared_target_emits_one_update_per_type_in_registry_order( + pm_with_sink, +): + pm, received = pm_with_sink + # two Types sharing one explicit Target, one Instance each; the + # Target is a root parameter, which is never an Instance (D12), and + # the two Instances differ in unit so each matches only its own Type + pm.add_parameter("shared_IF", initial_value=9e9, unit="Hz") + pm.add_parameter("q01.IF", initial_value=1e9, unit="Hz") + pm.add_parameter("q02.IF", initial_value=2e9, unit="V") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type("qubit2") + pm.add_type_parameter("qubit2", "IF", default=6e9, unit="V") + pm.lock_type_parameter("qubit", "IF", target="shared_IF") + pm.lock_type_parameter("qubit2", "IF", target="shared_IF") + received.clear() + + pm.remove_parameter("shared_IF") + + # one pm-lock-update per dropped Follower, then one pm-type-update per + # affected Type in registry order (qubit was created before qubit2) + assert [bp.action for bp in received] == [ + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + PM_TYPE_UPDATE, + ] + assert received[0].name == "parameter_manager.q01.IF" + assert received[1].name == "parameter_manager.q02.IF" + assert received[2].name == "parameter_manager.qubit" + assert received[3].name == "parameter_manager.qubit2" + for update in received[2:]: + assert isinstance(update.value, PMTypeBluePrint) + assert update.value.parameters["IF"]["target"] is None + + +def test_two_entries_sharing_a_removed_target_emit_one_type_update(pm_with_sink): + # two entries of one Type both declaring their Type Lock on the same + # ordinary parameter: the emission is one pm-type-update per affected + # Type, not per cleared entry + pm, received = pm_with_sink + pm.add_parameter("shared_IF", initial_value=9e9, unit="Hz") + pm.add_parameter("q01.IF", initial_value=1e9, unit="Hz") + pm.add_parameter("q01.octave_gain", initial_value=10, unit="dB") + pm.add_type("qubit") + pm.add_type_parameter("qubit", "IF", default=5e9, unit="Hz") + pm.add_type_parameter("qubit", "octave_gain", default=10, unit="dB") + pm.lock_type_parameter("qubit", "IF", target="shared_IF") + pm.lock_type_parameter("qubit", "octave_gain", target="shared_IF") + received.clear() + + pm.remove_parameter("shared_IF") + + # one pm-lock-update with None per dropped Follower, then exactly one + # pm-type-update whose blueprint shows both entries' Targets cleared — + # nothing else + assert [bp.action for bp in received] == [ + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ] + assert received[0].name == "parameter_manager.q01.IF" + assert received[0].value is None + assert received[1].name == "parameter_manager.q01.octave_gain" + assert received[1].value is None + update = received[2] + assert update.name == "parameter_manager.qubit" + assert isinstance(update.value, PMTypeBluePrint) + assert update.value.parameters["IF"]["target"] is None + assert update.value.parameters["octave_gain"]["target"] is None + + +def test_removing_a_target_of_ordinary_and_type_locks_emits_both_cleanups( + pm_with_sink, +): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + pm.add_parameter("q01Data.IF", initial_value=1e9, unit="Hz") + pm.lock("q01Data.IF", "_globals.qubit.IF") # an ordinary Follower too + received.clear() + + pm.remove_parameter("_globals.qubit.IF") + + # three dropped Followers in tree order — q01Data is a Parameter Group + # created after the Instances and the Globals submodule, so its + # Follower comes last — then one pm-type-update + assert [bp.action for bp in received] == [ + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ] + assert received[0].name == "parameter_manager.q01.IF" + assert received[1].name == "parameter_manager.q02.IF" + assert received[2].name == "parameter_manager.q01Data.IF" + assert received[3].name == "parameter_manager.qubit" + + +def test_remove_all_parameters_emits_the_type_lock_clearing(pm_with_sink): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + pm.remove_all_parameters() + + # remove_all_parameters deletes the Followers before the Globals + # Target, so no pm-lock-update is left to emit when the Target goes; + # the Type Lock clearing itself still emits its pm-type-update + assert [bp.action for bp in received] == [PM_TYPE_UPDATE] + assert received[0].name == "parameter_manager.qubit" + assert received[0].value.parameters["IF"]["target"] is None + + +def test_remove_type_emits_no_lock_update_and_leaves_everything_else(pm_with_sink): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + pm.remove_type("qubit") + + # exactly one pm-type-update with None and no pm-lock-update: the + # Globals parameters and every Instance Lock stay + assert len(received) == 1 + assert received[0].name == "parameter_manager.qubit" + assert received[0].action == PM_TYPE_UPDATE + assert received[0].value is None + assert pm.has_param("_globals.qubit.IF") + assert pm.list_locks() != {} + + +def test_a_refused_parameter_removal_emits_nothing(pm_with_sink): + pm, received = pm_with_sink + put_qubit_instances(pm) + pm.lock_type_parameter("qubit", "IF") + received.clear() + + with pytest.raises(KeyError): + pm.remove_parameter("q01.nope") + with pytest.raises(ValueError): + pm.remove_parameter("nope.IF") + + assert received == [] + + +# --------------------------------------------------------------------------- +# Type API and Broadcasts through a client proxy against a live Server +# (plan task 2.5) +# +# The Server registers itself as a Broadcast sink on the Parameter Manager +# (task 0.3), so every Type-editing method call over the wire also emits +# its Broadcasts on the PUB socket. The server-side Parameter Manager is +# shared by all tests of this module, so every test removes the parameters +# and Types it created again. +# --------------------------------------------------------------------------- + +PROXY_TYPE = "ptype_qubit" +PROXY_NESTED_TYPE = "ptype_readout" +PROXY_INSTANCE = "ptype_q01" + + +def _cleanup_proxy_types(params): + """Remove every parameter and Type the proxy tests create, so the + module's shared server-side Parameter Manager starts each test clean. + ``remove_type`` refuses while a Type nests another, so the nested map + is emptied and the outer Type is removed first.""" + for path in list(params.list()): + if path.split(".")[0].startswith("ptype_"): + params.remove_parameter(path) + for name in (PROXY_TYPE, PROXY_NESTED_TYPE): + if name in params.list_types(): + bp = params.get_type(name) + for submodule in list(bp.nested): + params.remove_nested_type(name, submodule) + params.remove_type(name) + + +def test_every_type_method_is_callable_through_the_proxy(param_manager): + cli, params = param_manager + _cleanup_proxy_types(params) + try: + params.add_type(PROXY_NESTED_TYPE) + params.add_type_parameter(PROXY_NESTED_TYPE, "IF", default=10e6, unit="Hz") + params.add_type(PROXY_TYPE) + params.add_type_parameter(PROXY_TYPE, "octave_gain", default=10, unit="dB") + params.add_nested_type(PROXY_TYPE, "ro", PROXY_NESTED_TYPE) + + assert sorted(params.list_types()) == [PROXY_TYPE, PROXY_NESTED_TYPE] + + params.set_type_parameter_default(PROXY_NESTED_TYPE, "IF", 20e6) + params.set_type_parameter_unit(PROXY_NESTED_TYPE, "IF", "V") + + params.add_instance(PROXY_NESTED_TYPE, PROXY_INSTANCE) + assert params.instances_of(PROXY_NESTED_TYPE) == [PROXY_INSTANCE] + assert params.types_of(f"{PROXY_INSTANCE}.IF") == [PROXY_NESTED_TYPE] + # the proxy method call does not refresh the proxy itself: after + # update() the created Instance shows up with the entry default + # and the propagated unit + params.update() + assert params.ptype_q01.IF() == 20e6 + assert params.ptype_q01.IF.unit == "V" + + # the removals work over the wire too + params.remove_type_parameter(PROXY_NESTED_TYPE, "IF") + assert params.get_type(PROXY_NESTED_TYPE).parameters == {} + params.remove_nested_type(PROXY_TYPE, "ro") + assert params.get_type(PROXY_TYPE).nested == {} + params.remove_type(PROXY_TYPE) + params.remove_type(PROXY_NESTED_TYPE) + assert params.list_types() == [] + finally: + _cleanup_proxy_types(params) + + +def test_get_type_and_list_types_deserialise_over_the_wire(param_manager): + cli, params = param_manager + _cleanup_proxy_types(params) + try: + params.add_type(PROXY_NESTED_TYPE) + params.add_type_parameter(PROXY_NESTED_TYPE, "IF", default=10e6, unit="Hz") + params.add_type(PROXY_TYPE) + params.add_type_parameter(PROXY_TYPE, "octave_gain", default=10, unit="dB") + params.add_nested_type(PROXY_TYPE, "ro", PROXY_NESTED_TYPE) + + types = params.list_types() + assert isinstance(types, list) + assert sorted(types) == [PROXY_TYPE, PROXY_NESTED_TYPE] + + bp = params.get_type(PROXY_TYPE) + assert isinstance(bp, PMTypeBluePrint) + assert bp.name == PROXY_TYPE + assert bp.parameters == { + "octave_gain": {"default": 10, "unit": "dB", "target": None}, + } + assert bp.nested == {"ro": PROXY_NESTED_TYPE} + assert bp.effective == { + "octave_gain": {"unit": "dB", "from_type": PROXY_TYPE}, + "ro.IF": {"unit": "Hz", "from_type": PROXY_NESTED_TYPE}, + } + + nested_bp = params.get_type(PROXY_NESTED_TYPE) + assert isinstance(nested_bp, PMTypeBluePrint) + assert nested_bp.parameters == { + "IF": {"default": 10e6, "unit": "Hz", "target": None}, + } + finally: + _cleanup_proxy_types(params) + + +def test_subclient_sees_pm_type_update_and_creations_from_a_second_client( + param_manager, server_port, capture_broadcasts, wait_for_broadcasts +): + cli, params = param_manager + _cleanup_proxy_types(params) + second_cli = Client(port=server_port) + try: + second_params = second_cli.find_or_create_instrument( + "parameter_manager", "instrumentserver.params.ParameterManager" + ) + with capture_broadcasts(["parameter_manager"], server_port + 1) as received: + # a Type edit from the second client: the SubClient sees the + # pm-type-update with the fresh blueprint + second_params.add_type(PROXY_NESTED_TYPE) + wait_for_broadcasts(received) + assert len(received) == 1 + bp = received[0] + assert isinstance(bp, ParameterBroadcastBluePrint) + assert bp.name == f"parameter_manager.{PROXY_NESTED_TYPE}" + assert bp.action == PM_TYPE_UPDATE + assert isinstance(bp.value, PMTypeBluePrint) + assert bp.value.parameters == {} + + second_params.add_type_parameter( + PROXY_NESTED_TYPE, "IF", default=5, unit="Hz" + ) + wait_for_broadcasts(received, n=2) + assert len(received) == 2 + assert received[1].action == PM_TYPE_UPDATE + assert received[1].value.parameters == { + "IF": {"default": 5, "unit": "Hz", "target": None}, + } + received.clear() + + # add_instance from the second client: exactly one + # parameter-creation per created parameter, and no + # pm-type-update, since the call edits no Type + second_params.add_instance(PROXY_NESTED_TYPE, PROXY_INSTANCE) + wait_for_broadcasts(received) + assert len(received) == 1 + creation = received[0] + assert creation.action == PARAMETER_CREATION + assert creation.name == f"parameter_manager.{PROXY_INSTANCE}.IF" + assert creation.value == 5 + assert creation.unit == "Hz" + finally: + second_cli.disconnect() + _cleanup_proxy_types(params) + + +def test_the_first_clients_proxy_shows_the_created_parameters_after_update( + param_manager, server_port +): + cli, params = param_manager + _cleanup_proxy_types(params) + second_cli = Client(port=server_port) + try: + second_params = second_cli.find_or_create_instrument( + "parameter_manager", "instrumentserver.params.ParameterManager" + ) + # the second client adds the Type, one entry and an Instance + second_params.add_type(PROXY_TYPE) + second_params.add_type_parameter(PROXY_TYPE, "IF", default=5, unit="Hz") + second_params.add_instance(PROXY_TYPE, PROXY_INSTANCE) + + # the first client's proxy predates the creations + assert PROXY_INSTANCE not in params.submodules + + # update() invalidates the cached blueprint and rebuilds the proxy + params.update() + assert PROXY_INSTANCE in params.submodules + assert params.ptype_q01.IF() == 5 + assert params.ptype_q01.IF.unit == "Hz" + finally: + second_cli.disconnect() + _cleanup_proxy_types(params) + + +def test_add_parameter_refusal_over_the_wire(param_manager): + cli, params = param_manager + before = sorted(params.list()) + + # the ValueError the server-side Parameter Manager raises reaches the + # client as an exception carrying the same message + with pytest.raises(Exception, match="the Globals submodule name is reserved"): + params.add_parameter("_globals.x") + + # nothing was created over the wire either + assert sorted(params.list()) == before + + +# --------------------------------------------------------------------------- +# Type Locks through a client proxy against a live Server (plan task 3.2) +# +# The Server registers itself as a Broadcast sink on the Parameter Manager +# (task 0.3), so a Type Lock declaration over the wire also emits its +# Broadcasts on the PUB socket. The server-side Parameter Manager is +# shared by all tests of this module, so every test removes the parameters +# and Types it created again — the Globals Targets included. +# --------------------------------------------------------------------------- + +PROXY_LOCK_TYPE = "ptl_qubit" +PROXY_LOCK_INSTANCES = ("ptl_q01", "ptl_q02") + + +def _cleanup_proxy_lock_types(params): + """Remove every parameter (the Globals Targets included) and the Type + the Type Lock proxy tests create, so the module's shared server-side + Parameter Manager starts each test clean.""" + for path in list(params.list()): + top = path.split(".")[0] + if top.startswith("ptl_") or top == "_globals": + params.remove_parameter(path) + if PROXY_LOCK_TYPE in params.list_types(): + params.remove_type(PROXY_LOCK_TYPE) + + +def test_lock_type_parameter_round_trips_over_the_wire(param_manager): + cli, params = param_manager + _cleanup_proxy_lock_types(params) + try: + params.add_type(PROXY_LOCK_TYPE) + params.add_type_parameter(PROXY_LOCK_TYPE, "IF", default=5e9, unit="Hz") + params.add_type_parameter(PROXY_LOCK_TYPE, "octave_gain", default=10, unit="dB") + for name in PROXY_LOCK_INSTANCES: + params.add_instance(PROXY_LOCK_TYPE, name) + params.update() + + # nothing is skipped: the return value is an empty list + assert params.lock_type_parameter(PROXY_LOCK_TYPE, "IF") == [] + + # get_type deserialises with the full-form Target + bp = params.get_type(PROXY_LOCK_TYPE) + assert isinstance(bp, PMTypeBluePrint) + assert bp.parameters["IF"]["target"] == ( + f"parameter_manager._globals.{PROXY_LOCK_TYPE}.IF" + ) + + # a locked Instance parameter pulls the Globals Target's value; + # the Target is set through the server-side Parameter Group's set + # (the client proxy's own set is qcodes' local, deprecated one) + cli.call("parameter_manager.set", f"_globals.{PROXY_LOCK_TYPE}.IF", 7e9) + assert getattr(params, PROXY_LOCK_INSTANCES[0]).IF() == 7e9 + + # a Follower locked to another Target comes back as the skipped + # list over the wire + params.add_parameter("ptl_target.IF", initial_value=1e9, unit="Hz") + params.lock(f"{PROXY_LOCK_INSTANCES[1]}.IF", "ptl_target.IF") + skipped = params.lock_type_parameter(PROXY_LOCK_TYPE, "IF") + assert skipped == [f"{PROXY_LOCK_INSTANCES[1]}.IF"] + + # the removal works over the wire too and clears the Target + params.unlock_type_parameter(PROXY_LOCK_TYPE, "IF") + assert params.get_type(PROXY_LOCK_TYPE).parameters["IF"]["target"] is None + # the Locks it created stay + assert params.get_lock(f"{PROXY_LOCK_INSTANCES[0]}.IF").locked is True + finally: + _cleanup_proxy_lock_types(params) + + +def test_subclient_receives_the_type_lock_broadcasts_from_a_second_client( + param_manager, server_port, capture_broadcasts, wait_for_broadcasts +): + cli, params = param_manager + _cleanup_proxy_lock_types(params) + second_cli = Client(port=server_port) + try: + second_params = second_cli.find_or_create_instrument( + "parameter_manager", "instrumentserver.params.ParameterManager" + ) + second_params.add_type(PROXY_LOCK_TYPE) + second_params.add_type_parameter(PROXY_LOCK_TYPE, "IF", default=5e9, unit="Hz") + second_params.add_type_parameter( + PROXY_LOCK_TYPE, "octave_gain", default=10, unit="dB" + ) + for name in PROXY_LOCK_INSTANCES: + second_params.add_instance(PROXY_LOCK_TYPE, name) + + with capture_broadcasts(["parameter_manager"], server_port + 1) as received: + second_params.lock_type_parameter(PROXY_LOCK_TYPE, "IF") + wait_for_broadcasts(received, n=4) + + # the Globals parameter-creation, one pm-lock-update per + # locked Instance parameter, then the pm-type-update + assert [bp.action for bp in received] == [ + PARAMETER_CREATION, + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + ] + assert received[0].name == ( + f"parameter_manager._globals.{PROXY_LOCK_TYPE}.IF" + ) + assert received[1].name == f"parameter_manager.{PROXY_LOCK_INSTANCES[0]}.IF" + assert isinstance(received[1].value, PMLockBluePrint) + assert received[1].value == PMLockBluePrint( + target=f"parameter_manager._globals.{PROXY_LOCK_TYPE}.IF", + locked=True, + ) + assert received[2].name == f"parameter_manager.{PROXY_LOCK_INSTANCES[1]}.IF" + assert received[3].name == f"parameter_manager.{PROXY_LOCK_TYPE}" + assert isinstance(received[3].value, PMTypeBluePrint) + assert received[3].value.parameters["IF"]["target"] == ( + f"parameter_manager._globals.{PROXY_LOCK_TYPE}.IF" + ) + + second_params.unlock_type_parameter(PROXY_LOCK_TYPE, "IF") + wait_for_broadcasts(received, n=5) + assert received[4].action == PM_TYPE_UPDATE + assert received[4].value.parameters["IF"]["target"] is None + finally: + second_cli.disconnect() + _cleanup_proxy_lock_types(params) + + +# --------------------------------------------------------------------------- +# Deletion interplay through a client proxy against a live Server +# (plan task 3.3) +# +# The Server registers itself as a Broadcast sink on the Parameter Manager +# (task 0.3) and announces direct remove_parameter calls with a +# parameter-deletion Broadcast, so removing a Globals Type Lock Target +# over the wire reaches a SubClient as one pm-lock-update with None per +# dropped Follower, the pm-type-update with the cleared Target, and the +# parameter-deletion — in this order. The server-side Parameter Manager +# is shared by all tests of this module, so every test removes the +# parameters and Types it created again. +# --------------------------------------------------------------------------- + +PROXY_DEL_TYPE = "ptd_qubit" +PROXY_DEL_INSTANCES = ("ptd_q01", "ptd_q02") + + +def _cleanup_proxy_deletion_types(params): + """Remove every parameter (the Globals Targets included) and the Type + the deletion-interplay proxy tests create, so the module's shared + server-side Parameter Manager starts each test clean.""" + for path in list(params.list()): + top = path.split(".")[0] + if top.startswith("ptd_") or top == "_globals": + params.remove_parameter(path) + if PROXY_DEL_TYPE in params.list_types(): + params.remove_type(PROXY_DEL_TYPE) + + +def test_removing_a_globals_type_lock_target_over_the_wire( + param_manager, server_port, capture_broadcasts, wait_for_broadcasts +): + cli, params = param_manager + _cleanup_proxy_deletion_types(params) + second_cli = Client(port=server_port) + try: + second_params = second_cli.find_or_create_instrument( + "parameter_manager", "instrumentserver.params.ParameterManager" + ) + second_params.add_type(PROXY_DEL_TYPE) + second_params.add_type_parameter(PROXY_DEL_TYPE, "IF", default=5e9, unit="Hz") + for name in PROXY_DEL_INSTANCES: + second_params.add_instance(PROXY_DEL_TYPE, name) + second_params.lock_type_parameter(PROXY_DEL_TYPE, "IF") + params.update() + + # the first client's Follower pulls the Globals Target's value + # while it exists (the Target is set through the server-side + # Parameter Group's set; the client proxy's own set is qcodes' + # local, deprecated one) + cli.call("parameter_manager.set", f"_globals.{PROXY_DEL_TYPE}.IF", 7e9) + assert getattr(params, PROXY_DEL_INSTANCES[0]).IF() == 7e9 + + with capture_broadcasts(["parameter_manager"], server_port + 1) as received: + second_params.remove_parameter(f"_globals.{PROXY_DEL_TYPE}.IF") + wait_for_broadcasts(received, n=4) + + # one pm-lock-update with None per dropped Follower, then the + # pm-type-update with the cleared Target, then the Server's + # parameter-deletion for the removed parameter + assert [bp.action for bp in received] == [ + PM_LOCK_UPDATE, + PM_LOCK_UPDATE, + PM_TYPE_UPDATE, + PARAMETER_DELETION, + ] + assert received[0].name == f"parameter_manager.{PROXY_DEL_INSTANCES[0]}.IF" + assert received[0].value is None + assert received[1].name == f"parameter_manager.{PROXY_DEL_INSTANCES[1]}.IF" + assert received[1].value is None + assert received[2].name == f"parameter_manager.{PROXY_DEL_TYPE}" + assert isinstance(received[2].value, PMTypeBluePrint) + assert received[2].value.parameters["IF"]["target"] is None + assert received[3].name == ( + f"parameter_manager._globals.{PROXY_DEL_TYPE}.IF" + ) + + # the first client's proxy shows the Follower unlocked after + # update(): it answers get with its own value again and its Lock + # is gone, and get_type over the wire shows the cleared Target + params.update() + assert getattr(params, PROXY_DEL_INSTANCES[0]).IF() == 5e9 + assert params.get_lock(f"{PROXY_DEL_INSTANCES[0]}.IF") is None + assert params.get_type(PROXY_DEL_TYPE).parameters["IF"]["target"] is None + finally: + second_cli.disconnect() + _cleanup_proxy_deletion_types(params) diff --git a/test/pytest/test_server_gui.py b/test/pytest/test_server_gui.py index 435e0cb..a420fa9 100644 --- a/test/pytest/test_server_gui.py +++ b/test/pytest/test_server_gui.py @@ -22,7 +22,14 @@ def _shutdown_server_window(window): pass -def test_saving_button(qtbot): +def _wait_until_client_points_at_server(qtbot, window, port): + """The embedded client connects to the default port when it is constructed + and only re-targets the real server port once the event loop delivers the + server-started signal, so the first request must not be sent before then.""" + qtbot.waitUntil(lambda: window.client.addr.endswith(f":{port}"), timeout=10000) + + +def test_saving_button(qtbot, server_port): correct_file_dict = { "rr.bandwidth": 10000.0, "rr.data": None, @@ -37,7 +44,8 @@ def test_saving_button(qtbot): "rr.stop_frequency": 20000000000.0, } - window = startServerGuiApplication() + window = startServerGuiApplication(port=server_port) + _wait_until_client_points_at_server(qtbot, window, server_port) window.client.find_or_create_instrument( "rr", "instrumentserver.testing.dummy_instruments.rf.ResonatorResponse" ) @@ -58,7 +66,7 @@ def test_saving_button(qtbot): _shutdown_server_window(window) -def test_loading_button(qtbot): +def test_loading_button(qtbot, server_port): correct_file_dict = { "dummy.A.ch0": 0, "dummy.A.ch1": 1, @@ -70,7 +78,8 @@ def test_loading_button(qtbot): "dummy.param1": 1, } - window = startServerGuiApplication() + window = startServerGuiApplication(port=server_port) + _wait_until_client_points_at_server(qtbot, window, server_port) file_path = Path(window._paramValuesFile) @@ -94,8 +103,9 @@ def test_loading_button(qtbot): _shutdown_server_window(window) -def test_refresh_button(qtbot): - window = startServerGuiApplication() +def test_refresh_button(qtbot, server_port): + window = startServerGuiApplication(port=server_port) + _wait_until_client_points_at_server(qtbot, window, server_port) qtbot.addWidget(window) try: assert window.stationList.topLevelItemCount() == 0 @@ -113,10 +123,11 @@ def test_refresh_button(qtbot): _shutdown_server_window(window) -def test_clicking_an_item(qtbot): +def test_clicking_an_item(qtbot, server_port): # If there is an exception raise, it will not reach the assert True statement - window = startServerGuiApplication() + window = startServerGuiApplication(port=server_port) + _wait_until_client_points_at_server(qtbot, window, server_port) qtbot.addWidget(window) try: assert window.stationList.topLevelItemCount() == 0 @@ -138,8 +149,9 @@ def test_clicking_an_item(qtbot): _shutdown_server_window(window) -def test_opening_new_tab_generic_object(qtbot): - window = startServerGuiApplication() +def test_opening_new_tab_generic_object(qtbot, server_port): + window = startServerGuiApplication(port=server_port) + _wait_until_client_points_at_server(qtbot, window, server_port) qtbot.addWidget(window) try: window.client.find_or_create_instrument( diff --git a/test/test_async_requests/serverConfig.yml b/test/test_async_requests/serverConfig.yml index baa4050..d12c497 100644 --- a/test/test_async_requests/serverConfig.yml +++ b/test/test_async_requests/serverConfig.yml @@ -11,7 +11,7 @@ instruments: gui: # By having the parameter manager GUI as the gui type, we can have it directly in the server instead of on a separate window. - type: instrumentserver.gui.instruments.ParameterManagerGui + type: instrumentserver.gui.parameter_manager.ParameterManagerGui test1: type: 'instrumentserver.testing.dummy_instruments.generic.DummyInstrumentTimeout'