Metadata-Version: 2.4
Name: lume-pva-apg
Version: 0.1.5
Summary: EPICS PV serving based on LUMEModel classes
Author: SLAC National Accelerator Laboratory, Lawrence Berkeley National Laboratory
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/als-apg/lume-pva
Project-URL: Repository, https://github.com/als-apg/lume-pva
Project-URL: Bug Tracker, https://github.com/als-apg/lume-pva/issues
Keywords: machine learning,accelerator physics
Classifier: Natural Language :: English
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Intended Audience :: Science/Research
Classifier: Topic :: Scientific/Engineering
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy
Requires-Dist: lume-base
Provides-Extra: ca
Requires-Dist: pcaspy>=0.8.1; extra == "ca"
Provides-Extra: pva
Requires-Dist: p4p>=4.2.2; extra == "pva"
Provides-Extra: torch
Requires-Dist: torch; extra == "torch"
Requires-Dist: lume-torch>=3.0.0; extra == "torch"
Provides-Extra: dev
Requires-Dist: pre-commit; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pyepics; extra == "dev"
Requires-Dist: lume-pva-apg[ca,pva]; extra == "dev"
Provides-Extra: docs
Requires-Dist: mkdocs; extra == "docs"
Requires-Dist: mkdocstrings; extra == "docs"
Requires-Dist: mkdocstrings-python; extra == "docs"
Requires-Dist: mkdocs-material; extra == "docs"
Requires-Dist: mkdocs-include-markdown-plugin; extra == "docs"
Dynamic: license-file

# lume-pva-apg

`lume-pva-apg` serves EPICS PVs based on a LUMEModel subclass and its supported
variables. It is a distribution of [lume-pva](https://github.com/lume-science/lume-pva)
maintained as a staging area for changes intended to land upstream; see
[Relationship to lume-pva](#relationship-to-lume-pva) below.

Features:
* Model outputs served over PVAccess (PVA) and/or ChannelAccess (CA).
    * PVA PVs support a subset of the EPICS NormativeTypes metadata.
    * CA PVs support a subset of the standard EPICS CA meta, such as alarms, display limits and control limits.
* Model inputs served as writable PVs over PVA and/or CA.

## Installation

The core distribution is pure-Python and carries no EPICS transport, so it
installs from wheels on every platform without triggering a native build.
Serving requires at least one transport extra:

```sh
pip install 'lume-pva-apg[ca,pva]'   # both transports
pip install 'lume-pva-apg[pva]'      # PVAccess only
```

| Extra | Pulls in | Needed for |
| --- | --- | --- |
| `pva` | `p4p` | PVAccess serving, and the NormativeTypes value layer |
| `ca` | `pcaspy>=0.8.1` | Channel Access serving |
| `torch` | `torch`, `lume-torch` | the `TorchScalarVariable` / `TorchNDVariable` types |

The `pva` extra is required for any serving at all: the value layer in
`lume_pva_apg.variables` is expressed in p4p types and is used by the CA path
too. A CA-only install is therefore not currently possible. Importing a module
whose transport is absent raises an `ImportError` naming the extra to install.

`pcaspy>=0.8.1` is a floor rather than an exact pin. On 0.8.0 the asynchronous
write path passes a `casClientInfo` where a `casCtx` is required, which raises
inside the SWIG director and terminates the server process; a client issuing a
put with callback is told the write succeeded while the value never lands.
0.8.1 fixes it, and the fix depends on a binding 0.8.0 does not compile, so it
cannot be worked around from Python. 0.8.1 publishes no linux/aarch64 wheel, so
the bound is left open: arm64 consumers must stay free to build from sdist or
to take a later release that restores the wheel.

## Basic Usage

```py
from lume_pva_apg.runner import Runner

myModel = MyLUMEModel()
r = Runner(model=myModel)
r.run()
```

## Operational Description

### Model Outputs

Model outputs can be served over PVA and/or CA. If the output is solely an output, it will be configured as a read-only PV.

### Model Inputs

Model inputs are served by the `Runner` class and can be interacted with using pvput, caput or other
CA/PVA tools on the command line.

Like model outputs, inputs can be served over CA or PVA, depending on the `Runner` configuration.

### The Write Path

A write to an input PV is queued, applied to the model on the next simulation
cycle, and only then acknowledged: a `caput -c` or a PVA put with `wait=True`
stays blocked until the cycle that consumed it has finished. Three properties
of what happens in between are selectable, and each defaults to the behaviour
the runner has always had.

`echo_unconfirmed_writes` (default `True`) decides whether an input PV may
advertise a value the model has not accepted. By default the requested value is
published immediately on PVA, and recorded on CA whether or not the cycle
succeeded, so a client reading back an input after a failed cycle can see a
value the model never took. Set it to `False` and the echo waits for the model
to accept the write, and is withheld entirely if the model refuses it, leaving
the PV on the last value that actually landed.

`alarm_on_refused_write` (default `False`) raises `WRITE_ALARM`/`INVALID_ALARM`
on the CA PV when the model refuses a write. Channel Access put-completion
carries no failure channel — it can only ever report success — so an alarm is
the only way to tell a CA client its write did not land. PVAccess reports the
failure through the put itself and needs no equivalent.

`clamp_writes` (default `False`) clamps a written value into the variable's
`value_range` before handing it to the model. `LUMEModel.set` does not enforce
`value_range`, so by default an out-of-range write reaches the model unchanged
and it is the model's job to reject it. The clamp is applied where the write
enters the server, so the value echoed back to the client is the same value the
model was given.

`update_rate` is the length in seconds of the window during which arriving
writes are batched into a single `model.set()`. Set it to zero and the window
is skipped: every queued write drives a `model.set()` of its own, so one
client's write is never merged into another's.

### Control PVs

The runner exposes a control PV of its own:
* `{prefix}RESET`: any write requests `model.reset()` and publishes the reset state to output PVs.

The control PV is served over PVA, and is also available over CA when `protocol` includes `"ca"`.

Set `control_pvs` to `False` and the runner serves the model's variables and
nothing else. Use it when the runner shares its prefix with another server, or
when the surrounding deployment offers reset through a channel of its own, so
the runner claims no name the model did not ask for.

`prefix` is passed to the constructor of the `Runner` class and defines a prefix to prepend to the start of PV names.

### Extension Points

A consumer that needs to serve more than the model describes can subclass
`Runner` rather than fork it. Each seam below defaults to what the base class
has always done.

`_extend_pvdb()` returns additional pcaspy database entries. They are keyed by
base PV name, exactly as the runner's own are — `prefix` is applied by the
server, not by the caller. It is called once, after the model's variables and
the control PVs are in the database and before the server is created, and only
when `protocol` includes `"ca"`. Merging refuses to shadow a name already
there, so a contributed PV can never silently replace one the model owns.

`ca_driver_cls` is the driver class instantiated to serve that database,
constructed with the runner as its only argument. The stock driver resolves a
`reason` through the model's variables and refuses anything it cannot find, so
a subclass contributing PVs of its own supplies a driver that knows how to
write them and delegates the rest to `super()`. Reads need no override: the
pcaspy default reads back through `getParam`.

`_post_outputs(out_values, ts)` is the run loop's publishing step, given the
values `model.get` returned and the timestamp to stamp them with. Overriding it
lets a subclass publish elsewhere, publish more, or publish nothing. Raising
from it fails the cycle exactly as raising inline did: the model is rolled back
to its cached state and every waiting put completes with the error.

```py
class CoHostedRunner(Runner):
    class Driver(Runner.CaDriver):
        def write(self, reason, value):
            if reason == "STATUS":
                self.setParam(reason, value)
                self.updatePV(reason)
                return True
            return super().write(reason, value)

    ca_driver_cls = Driver

    def _extend_pvdb(self):
        return {"STATUS": {"type": "int", "value": 0}}

    def _post_outputs(self, out_values, ts):
        self.ca_driver.setParam("STATUS", len(out_values))
        super()._post_outputs(out_values, ts)
```

### Configuration

`Runner.generate_config()` can be used to generate a `dict` describing the default configuration for the model.
You can either edit this on the fly, or serialize it and edit it by hand later.

```py
print(Runner.generate_config(model=myModel))
```

An example configuration:
```py
{
    'prefix': 'MY_PV_PREFIX:',
    'update_rate': 0.1, # Update period under which PVs will be batched together into one model.set(). Set to 0 to disable the window.
    'protocol': ['ca', 'pva'], # Serve this as both CA and PVA (the default)
    'echo_unconfirmed_writes': True, # Echo a written value before the model has accepted it
    'alarm_on_refused_write': False, # Alarm the CA PV when the model refuses a write
    'clamp_writes': False, # Clamp a written value into the variable's value_range
    'control_pvs': True, # Serve the runner's own {prefix}RESET control PV
    'variables': {
        'input_a': {
            'name': 'input_a',
            'pv': 'input_a_pv',
            'mode': 'rw', # 'rw' means we can read and write this PV
            'precision': 3, # Optional, float scalars only: display precision (0..17), CA `prec` and PVA `display.precision`
            'description': 'Input A' # Optional, scalar/str/bool/enum only: PVA `display.description`; defaults to the variable's own description (lume-base >= 0.6)
        },
        'output_b': {
            'name': 'output_b',
            'pv': 'output_b_pv',
            'mode': 'ro' # 'ro' means the PV is read-only
        }
    }
}
```

### Consumer contract

A deployment that configures the runner, supplies its model, or subclasses it
relies on the names and rules below. They are stable across patch releases;
anything not listed here is an implementation detail.

**Configuration keys.**

* `config["variables"][name]["precision"]`: optional. Must be an `int` (not a
  `bool`) in `0..17`, and is allowed only on a float scalar variable — a
  `ScalarVariable` that is not an `IntVariable`. Int, bool, string, enum, array
  and Torch variables reject it. A configured precision reaches CA as `prec`
  and PVA as `display.precision`; a float without one keeps its previous
  display fields, `display.format` included.
* `config["variables"][name]["description"]`: optional. Must be a `str`, and is
  allowed on scalar, int, string, bool and enum variables; array and Torch
  variables reject it. Without one, the variable's own `description` is used
  where the installed `lume-base` has it (0.6 and later). It reaches PVA as
  `display.description`; an empty string means no description. The model's
  variable is never modified.
* `config["tick_interval_s"]`: optional. Absent or `None` means no periodic
  passes. Otherwise it must be an `int` or `float` (not a `bool`), finite and
  greater than zero, in seconds.

Every violation raises `ValueError` from the `Runner` constructor, naming the
variable and the key. A variable's `value_range` always comes from the model's
Variable: a `value_range` key in the config entry is ignored.

**The `output_severity` hook.** A model may define

```py
def output_severity(self, names: list[str]) -> dict[str, dict[str, str]]:
    return {"some_output": {"condition": "udf"}}
```

It is called once per pass, on the run-loop thread, before anything is
published, with the names of the outputs that pass publishes. Each name it
reports `"udf"` is published as (INVALID, UDF) on both transports until a later
pass stops reporting it. Only requested names are evaluated: keys outside
`names` are ignored, so a model may report on outputs the pass does not
publish. For a requested name, anything other than `{"condition": "udf"}` —
an unknown condition, a non-dict entry, a reply that is not a dict — raises
`ValueError`, nothing is published, and the pass fails as any failed pass does
(the model rolls back and every waiting put completes with the error).

Reporting a name undefined does not excuse its value. `model.get` validates
every output against its variable before publishing starts, so an undefined
output must still return a type-valid value: a float may be NaN, and an enum's
value must be one of its options. An invalid value fails the pass.

**Alarm-aware `_generate_value`.** `_generate_value(name, value, ts)` applies
the UDF alarm to any name currently undefined, so a subclass publishing
through it — its own echo, an extra post — carries the model's latest verdict
without further work. `_pack_value` builds the same Value without that
overlay. One race remains: a `_generate_value` called off the run-loop thread
for a name, between the moment a pass records its new verdict and the moment
that pass publishes the name, may carry the previous verdict. The next pass
that publishes the name corrects it, which with `tick_interval_s` set happens
within one tick.

**Periodic-pass signals.** Every queued item carries `item["tick"]`: `False`
for a write or reset, `True` for a periodic pass. Read it as
`item.get("tick", False)`, since items a subclass builds may lack the key.
During a pass, `self._pass_is_tick` is true only when every item of the batch
was a tick and the batch carries no input values and no reset; it is set
before the pass's jobs run, so `_cycle_output_names` and `_post_outputs` can
read it. A subclass running with `update_rate` of zero may equally treat
"input values present" as the mark of a write pass.

**Intended wire changes.** Three differences a client can observe are
deliberate:

1. An `IntVariable` is served on PVA as an NTScalar `"i"` (int32, the width of
   a CA `long`) rather than a double, and reads back as an integer.
2. String and bool NTScalars and NTEnums carry a `display` block.
3. When building a pass's outputs fails, nothing from that pass is published on
   either transport. Previously a CA conversion error could leave part of the
   pass published.

## Relationship to lume-pva

This distribution exists so that changes can be exercised against a real
deployment before they are proposed upstream. It is a staging area, not a
long-lived divergence: each entry below is either an intended upstream pull
request or a packaging consequence of publishing under a second name, and the
distribution retires once the upstream ones are merged and released.

### Intended upstream pull requests

| Change | Rationale |
| --- | --- |
| Declare `p4p` as a first-class dependency behind a `pva` extra | `p4p` is imported unconditionally by `variables.py` and `runner.py` but was declared only under the `dev` extra, so a non-dev install of the project could not import its own runner. |
| Split the EPICS transports into `ca` and `pva` extras, leaving a pure-Python core | Installing the project currently forces a `pcaspy` build on any platform without a matching wheel, even for consumers that only generate configuration or only speak one protocol. |
| Raise the `pcaspy` floor to `>=0.8.1` | On 0.8.0 an asynchronous CA write raises inside the SWIG director and terminates the server process, while the client is told the put succeeded. The fix needs a binding 0.8.0 does not compile, so a version floor is the only remedy. |
| Replace the `lume-torch` VCS reference with the released `lume-torch>=3.0.0` | PyPI rejects direct-URL dependencies in any extra, so the `torch` extra as written makes the project unpublishable. |
| Adopt the PEP 639 SPDX licence expression and drop the deprecated `[project.license]` table | Published metadata is immutable, so the deprecated form has to go before a release rather than after one. |
| Drop `pydantic` and `pyyaml` from the dependency list | Neither is imported anywhere in the package. |
| Move CI from conda to `uv`, pin the interpreter per matrix leg, and assert it at runtime | A matrix that silently degrades to one interpreter tests the same leg repeatedly and stays green. |
| Run the test suite from outside the checkout, against the installed distribution | Run from the source tree, the tests import the adjacent package regardless of what the packaging metadata ships, so a packaging mistake cannot fail the build. |
| Add a job asserting the core install is pure-Python on Linux and macOS | The pure-Python core is a property consumers depend on to provision a host without an EPICS toolchain. Stated only in a comment it decays; the job checks the built wheel is `py3-none-any` and that no transport reaches the environment. |
| Add a job rejecting direct-URL dependencies in the metadata | PyPI refuses a distribution carrying one, in any extra. Without the gate this is discovered by the release that fails, after the tag is spent. |
| Flush the input echo before signalling put-completion | pcaspy publishes a value to monitoring clients only when `updatePV` runs, so a client released by put-completion was told its write had finished while its own monitor still carried the value that write replaced. |
| Withhold the input echo when the model refuses a write (`echo_unconfirmed_writes`) | A failed simulation cycle leaves the model on its previous value, but the requested value is recorded on the input PV regardless, so a read-back reports a value the model never took. Put-completion cannot report the failure, so withholding the echo is the only signal available. |
| Alarm a refused write (`alarm_on_refused_write`) | Channel Access put-completion ends an asynchronous write with `S_casApp_success` unconditionally; there is no failure channel. An alarm is the only way to tell a CA client its write did not land. |
| Skip the batching window when `update_rate` is zero | The window was skipped only because its deadline had already elapsed by the time it was tested, making per-write isolation an accident of the clock rather than something the documented `update_rate` of zero guarantees. |
| Clamp a write into the variable's `value_range` (`clamp_writes`) | `LUMEModel.set` does not enforce `value_range`, so an out-of-range write reaches the model unchallenged and, on failure, costs a whole simulation cycle. Applied at the point the write enters the server, so the echo matches what the model was given. |
| Let a subclass contribute PVs to the served CA database (`_extend_pvdb`) | A consumer serving anything the model does not describe — a status channel, a second model's outputs — currently has to reach into `Runner.pvdb` between construction and the server being created, which is not a moment the constructor offers. A hook called before `createPV` gives it one, and refusing to shadow an existing name keeps the model's own PVs the model's. |
| Make the CA driver class a class attribute (`ca_driver_cls`) | The driver was constructed as `Runner.CaDriver(self)`, naming the class literally, so serving a reason the stock driver does not know required replacing the constructor rather than the driver. |
| Factor the run loop's output publishing into an overridable method (`_post_outputs`) | The publishing step was inline in `_run`, so a subclass that wanted to publish anything alongside the model's outputs had to reimplement the whole loop — batching window, cached-state rollback, put-completion signalling and all. |
| Make the runner's control PVs optional (`control_pvs`) | `{prefix}RESET` is claimed unconditionally, which is a name collision waiting to happen for a runner sharing its prefix with another server, and dead weight where the deployment offers reset through a channel of its own. |
| Publish `value_range` as CA display limits only, not as alarm thresholds | pcaspy compares `lolo`/`hihi` with `<=`/`>=`, so thresholds taken from the variable's own range put a value driven to either end of its legal span into MAJOR alarm — where the PVA path, which compares strictly, reports no alarm for the same value. This does not preserve the old behaviour for a value *outside* the range, which no longer alarms on CA at all; pcaspy's inclusive comparison cannot express a threshold that alarms outside the range without also alarming at it. |
| Add a CI leg that runs the suite without the `torch` extra | Every other leg installs everything, so a module-scope import of an optional dependency passes them all and then takes collection — the whole run, not one file — down for any consumer who installed less. The leg also asserts every shipped test module contributed a passing test, so a guard widened until it skips whole modules cannot stay green either. |
| Add a CI leg that installs the built sdist and runs the suite out of it | Every other leg installs from the checkout, where every file is present whether or not the packaging ships it, so a file left out of the sdist passes the whole suite and fails on the first consumer to install the release — after the version number is spent. |
| Guard the test suite's optional imports so a partial install skips rather than failing collection | The suite imported `epics`, `torch` and `p4p` at module scope, so anything short of the full dev-plus-torch set died during collection and ran nothing at all — including the tests that needed none of them. Guarding turns "not installed" into a reported skip of the one module that could not run. |
| Report an absent optional dependency as a `ModuleNotFoundError` carrying `name` | Absent and broken were distinguished in the message but not in the exception, so a caller reraising one — the suite's own import guards do — saw every failure as the broken case and could not decide whether to skip. |
| Add tests for what an install guarantees | The pure-Python core is a property of what the modules import, not only of what the wheel declares: a convenience re-export in `__init__` would pull a transport into the core install while the metadata still called it pure. Asserted by importing in a fresh interpreter with the optional dependencies made unimportable, which also covers the two answers given for an unusable transport. |
| Cover the configurations the constructor rejects | `generate_config` is documented as something to edit before passing on, so the `variables` table reaches the constructor hand-written: a name the model does not have, an unknown `mode`, or a writable PV over a read-only variable each serve a PV that accepts writes and drops them. Nothing exercised any of the validation. |
| Cover the CA display limits for a variable with a non-default range | Every `ca_pvspec` case used a variable with no range and no unit, so a spec that dropped the limits, zeroed them, or swapped them was indistinguishable from a correct one. |
| Apply the PV name prefix exactly once on the Channel Access path | `prefix` was written into the pvdb keys and then applied again by `SimpleServer.createPV`, so a runner configured with `PFX:` served `PFX:PFX:name`. The driver names a PV by its pvdb key, so the same mistake left the cycle's output pass calling `setParam` with a name the database did not hold: every cycle raised `KeyError` and was logged as a failed simulation. Keying the database by base name leaves the prefix to the server, which is also what names a PV in every driver callback. Invisible at `prefix=""`, which is what every existing test used. |
| Serve an `IntVariable` as an int32 NTScalar on PVA | `IntVariable` subclasses `ScalarVariable`, and `create_type` tested for the base class first, so every integer variable was served as an NTScalar double and packed as a float. A PVA client saw a floating-point PV for an integer quantity; testing `IntVariable` first gives it wire code `i` and packs the value as an int. |
| Configure a variable's display `precision` and `description` | A client had no way to learn how many digits a float is meaningful to, or what a PV is, other than out of band. Both are per-variable configuration keys validated at construction: `precision` (float scalars only) reaches CA as `prec` and PVA as `display.precision`, and `description` — or, without one, the variable's own — reaches PVA as `display.description` on every type with a display block. A float configured without a precision keeps the display fields it had before. |
| Let a model mark outputs undefined for a cycle (`output_severity`) | A model whose calculation failed for some outputs but not others had no way to say so: the stale or placeholder value was published with no alarm, indistinguishable from a real one. A model defining `output_severity(names)` returns `{name: {"condition": "udf"}}` for the outputs it could not compute, and each is published as (INVALID, UDF) on both transports until a later cycle reports it defined again. The verdict is validated before anything is posted, so a malformed one fails the cycle rather than publishing half of it, and a CA put on an undefined variable restates the UDF alarm after its echo, since `setParam` would otherwise clear it. Names reported undefined must still carry values of their declared type. |
| Run a periodic model pass (`tick_interval_s`) | A model that evolves on its own — a relaxation, a drifting source, anything driven by time rather than by writes — was evaluated only when a client wrote an input, so between writes its outputs stood still. With `tick_interval_s` set, a daemon thread queues a pass with no input values every that many seconds for as long as the run loop runs. At most one such pass is ever waiting, so a model slower than the interval runs back to back rather than building a backlog, and a tick merged into a batch of writes rides along with it. A subclass can tell a pure periodic pass from a write by `_pass_is_tick`. |
| Raise the `p4p` floor to `>=4.2.2` | The `pva` extra accepted any `p4p`, but 4.2.2 is the only release on which the runner's use of `form=True` has been verified, and it is the version OSPREY deployments lock. An unbounded requirement lets a resolver pick an older release that was never tested against the runner. |
| Add a CI leg that runs the suite on `lume-base==0.5.0` | `lume-base` is an unpinned core dependency, so every other leg tests whatever release is newest that day, and a use of API an older release lacks passes CI and fails for a consumer whose environment is locked. Pinning 0.5.0 in the leg rather than in the metadata proves the oldest supported release still works while leaving consumers free to move. |

### Fork-local changes

| Change | Rationale |
| --- | --- |
| Import package renamed to `lume_pva_apg`, distribution to `lume-pva-apg` | Two distributions cannot share an import package; the rename is what makes the two installable side by side. Retired when the fork is. |
| CI job failing on a facility-specific reference in the package | The staging fork is edited alongside a facility deployment, which is exactly the condition under which a site-specific name gets committed by accident. The gate keeps every change here shaped as something upstream can take. |
| Dropped the `no-commit-to-branch` pre-commit hook | Changes land on `main` here before they are proposed upstream, so the hook blocks the fork's only workflow. |
| Removed the `pvua` dependency and the remote-input mode it backed | `pvua` is only available as a VCS reference, which makes the project unpublishable, and the remote mode is unused here. Dropped rather than reworked: upstream owns `pvua` and should keep the feature, so this is not offered as a pull request. |

Removing the remote-input mode also removed what depended on it: the `remote`
PV mode, the `remote_model_mode` config key, the `remote_inputs` argument to
`generate_config`, snapshot mode, and the `{prefix}SNAPSHOT` control PV, whose
only purpose was to trigger a pull from remote inputs.

## Supported Variables

Supported variable types and their metadata fields.

### `ScalarVariable` and `IntVariable`

Represented as **NTScalar** with a `double` or `int` value field (depending on variable type).

Supported metadata:
* `timestamp`
* `display.units`
    * `ScalarVariable.unit`
* `control.limitLow`
    * `ScalarVariable.value_range[0]`
* `control.limitHigh`
    * `ScalarVariable.value_range[1]`
* `alarm.severity` and `alarm.status` (PVA only)
    * Set from the value's position relative to `value_range`: a value strictly outside it is a MAJOR alarm, a value anywhere within it — including at either limit — is no alarm.

On CA, `value_range` is served as the display limits (`lolim`/`hilim`) and is
not served as an alarm threshold. pcaspy compares its `lolo`/`hihi` thresholds
inclusively, so a range published as an alarm limit puts a value driven to
either end of its own legal span into MAJOR alarm. A CA client therefore sees
no range-derived alarm at all, while a PVA client still sees one for a value
outside the range.

### `NDVariable`

Represented as **NTNDArray** with data representation matching the numpy shape and dtype.

Supported metadata:
* `timestamp`

### `TorchScalarVariable`

Requires the `torch` extra (`pip install 'lume-pva-apg[torch]'`).

Represented as **NTScalar** with a `double` value field.

Supported metadata:
* `timestamp`

### `TorchNDVariable`

Requires the `torch` extra (`pip install 'lume-pva-apg[torch]'`).

Represented as **NTNDArray** with data representation matching the Tensor shape and dtype.

Supported metadata:
* `timestamp`

### `BoolVariable`

Represented as **NTScalar** with a `bool` value field.

Supported metadata:
* `timestamp`

### `StrVariable`

Represented as **NTScalar** with a `str` value field.

Supported metadata:
* `timestamp`
