alicatlib¶
Top-level re-exports. Most users only need names from this module.
alicatlib ¶
alicatlib — Python library for the full Alicat instrument matrix.
Covers {flow, pressure} × {meter, controller} × {gas, liquid}, plus the
CODA Coriolis line. Orthogonal :class:Medium gating (design §5.9a)
lets every command declare which media it applies to; the session
refuses cross-medium dispatch pre-I/O with
:class:AlicatMediumMismatchError. Devices whose prefix doesn't
uniquely determine the configured medium can be narrowed at open time
via assume_media= on :func:~alicatlib.devices.factory.open_device.
Core API is async (built on anyio); a sync facade is available at
:mod:alicatlib.sync for scripts, notebooks, and REPL use.
See docs/design.md for the architectural design.
AcquisitionSummary
dataclass
¶
AcquisitionSummary(
started_at,
finished_at=None,
samples_emitted=0,
samples_late=0,
max_drift_ms=0.0,
)
Per-run summary owned by the recorder.
Mutability contract (per the cross-lib spec §M):
- The recorder is the only writer. It updates counters in place during the run so progress polling (TUIs, dashboards) works without a separate API.
- Consumers treat the summary as read-only.
- :attr:
finished_atisNonewhile the recording is in flight and is set on context-manager exit.
Attributes:
| Name | Type | Description |
|---|---|---|
started_at |
datetime
|
Wall-clock at recorder entry. |
finished_at |
datetime | None
|
Wall-clock at producer shutdown — |
samples_emitted |
int
|
Count of per-tick batches actually pushed
onto the receive stream. Partial batches (some devices
errored under |
samples_late |
int
|
Count of ticks that missed their target slot (producer overran the previous tick, or overflow policy dropped the batch). |
max_drift_ms |
float
|
Largest observed positive drift of an emitted
batch relative to its absolute target, in milliseconds.
A healthy run stays well under one period; values
approaching |
AlicatCapabilityError ¶
Bases: AlicatError
The device cannot perform the requested command.
Source code in src/alicatlib/errors.py
AlicatCommandRejectedError ¶
Bases: AlicatProtocolError
The device replied with its error marker (? / similar).
Source code in src/alicatlib/errors.py
AlicatConfig
dataclass
¶
AlicatConfig(
default_timeout_s=0.5,
multiline_timeout_s=1.0,
write_timeout_s=0.5,
default_baudrate=19200,
drain_before_write=False,
save_rate_warn_per_min=10,
eager_tasks=False,
)
Process-wide default settings.
Individual sessions may override any of these at construction time.
Attributes:
| Name | Type | Description |
|---|---|---|
default_timeout_s |
float
|
Default per-command response timeout for single-line commands, in seconds. |
multiline_timeout_s |
float
|
Default response timeout for multiline table
commands ( |
write_timeout_s |
float
|
Upper bound on a single |
default_baudrate |
int
|
Default serial baudrate when none is specified. |
drain_before_write |
bool
|
Whether the protocol client should drain any stale input bytes before each command. Useful for re-syncing after a timeout; adds latency per command. |
save_rate_warn_per_min |
int
|
EEPROM-wear warning threshold. Any command
carrying a |
eager_tasks |
bool
|
Opt-in to |
AlicatConfigurationError ¶
AlicatConnectionError ¶
Bases: AlicatTransportError
Connection could not be established or was lost.
Source code in src/alicatlib/errors.py
AlicatDeviceSnapshot
dataclass
¶
AlicatDeviceSnapshot(
name,
model,
firmware,
serial,
connected,
last_error,
recoverable_error_count,
captured_at,
unit_id,
media,
capabilities,
)
Bases: DeviceSnapshot
Alicat-specific :class:DeviceSnapshot extras.
Adds the Alicat-native bus address, media flags, and capability
set. Inherits every base field from :class:DeviceSnapshot so
consumers that want only the cross-lib surface can still pattern-
match on the base type.
AlicatDiscoveryError ¶
AlicatError ¶
Bases: Exception
Base class for every exception raised by :mod:alicatlib.
Carries a typed :class:ErrorContext. The message is the human-readable
summary; the context is the machine-readable detail.
Source code in src/alicatlib/errors.py
with_context ¶
Return a copy of this error with its context updated.
Useful when an inner layer raises and an outer layer wants to enrich
the context (for instance adding port or elapsed_s).
Allocates a fresh instance via cls.__new__ and copies attribute
state directly. Avoids re-invoking __init__ — many subclasses
(AlicatMediumMismatchError, AlicatFirmwareError,
UnknownGasError and friends) have bespoke keyword-only
signatures that don't accept (message, *, context=), and
copy.copy would silently dispatch through them via
:meth:Exception.__reduce__.
Source code in src/alicatlib/errors.py
AlicatFirmwareError ¶
AlicatFirmwareError(
*,
command,
reason,
actual=None,
required_min=None,
required_max=None,
required_families=None,
context=None,
)
Bases: AlicatCapabilityError
The device's firmware version is outside the command's supported range.
Source code in src/alicatlib/errors.py
AlicatManager ¶
Coordinator for many devices across one or more serial ports.
Operations run concurrently across different physical ports (via
:func:anyio.create_task_group) and serialise on the same-port
client lock. Per-device failures are surfaced per
:attr:error_policy:
- :attr:
ErrorPolicy.RAISE: the manager still collects results from every device, then raises an :class:ExceptionGroupif any failed. - :attr:
ErrorPolicy.RETURN: the mapping's values carry :class:DeviceResultcontainers with.valueor.error.
Usage::
async with AlicatManager() as mgr:
await mgr.add("fuel", "/dev/ttyUSB0")
await mgr.add("air", "/dev/ttyUSB1")
frames = await mgr.poll()
Source code in src/alicatlib/manager.py
__aenter__
async
¶
__aexit__
async
¶
Close every managed device + port on exit.
add
async
¶
Register and open a device under name.
The source discriminates lifecycle ownership:
Device— pre-built (via :func:open_deviceoutside the manager). The manager only tracks the name mapping; it does not take lifecycle ownership.str— serial port path ("/dev/ttyUSB0","COM3"). The manager creates a :class:~alicatlib.transport.serial.SerialTransportand :class:AlicatProtocolClient, canonicalises the port key, and reuses them across multi-device buses (RS-485).- :class:
Transport— duck-typed transport. The manager wraps it in a new client but does not take transport ownership (the caller keeps open/close responsibility). - :class:
AlicatProtocolClient— use as-is; the manager does not close it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Unique manager-level identifier. Must not already exist on this manager. |
required |
source
|
Device | str | Transport | AlicatProtocolClient
|
One of the four lifecycle shapes above. |
required |
unit_id
|
str
|
Bus-level letter for the device. |
'A'
|
serial
|
SerialSettings | None
|
:class: |
None
|
timeout
|
float
|
Default command timeout passed through to
:func: |
0.5
|
Returns:
| Type | Description |
|---|---|
Device
|
The identified :class: |
Device
|
class: |
Raises:
| Type | Description |
|---|---|
AlicatValidationError
|
|
AlicatConnectionError
|
The manager is closed. |
Source code in src/alicatlib/manager.py
271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 | |
close
async
¶
Tear down every managed device and port (LIFO).
Idempotent: safe to call from both :meth:__aexit__ and
explicit user code. Individual close failures are caught and
logged so one device's shutdown error doesn't strand the
others.
Source code in src/alicatlib/manager.py
execute
async
¶
Dispatch a per-device Command across the requested names.
requests_by_name chooses both which devices participate and
what arguments each gets — supporting the common case of
"same command, different setpoint per device".
Source code in src/alicatlib/manager.py
get ¶
Return the device registered under name (raises if unknown).
Source code in src/alicatlib/manager.py
poll
async
¶
Poll every (or named) device concurrently across ports.
Returns a mapping from device name to :class:DeviceResult
even under :attr:ErrorPolicy.RAISE — but under that policy,
any failed device's error is re-raised as an
:class:ExceptionGroup after all devices have completed.
Source code in src/alicatlib/manager.py
remove
async
¶
Unregister and close the device named name.
If name was the last device on a shared port, the
transport and client for that port are closed too. A
pre-built :class:Device source is only dropped from the
manager's registry — the caller retains lifecycle ownership.
Source code in src/alicatlib/manager.py
request
async
¶
Run :meth:Device.request across devices concurrently.
Every targeted device receives the same statistic list and
averaging window — mirroring the primer's DV semantics.
Source code in src/alicatlib/manager.py
AlicatMediumMismatchError ¶
Bases: AlicatConfigurationError
A command's declared medium doesn't intersect the device's configured medium.
Raised pre-I/O from :class:alicatlib.devices.session.Session at the
media gate (design §5.4, §5.9a). The typical shape: calling
:meth:Device.gas on a liquid-only device, or
:meth:Device.fluid on a gas-only device. The error carries the
mismatch in :attr:ErrorContext.device_media and
:attr:ErrorContext.command_media and points at the remediation
API in its message.
Source code in src/alicatlib/errors.py
AlicatMissingHardwareError ¶
Bases: AlicatCapabilityError
The device lacks hardware the command requires.
Raised from :class:alicatlib.devices.session.Session before any
I/O, using the :class:alicatlib.commands.base.Capability bits declared
on the :class:alicatlib.commands.base.Command spec. More useful than
letting the device silently respond ? — tells the caller exactly
which capability is missing (BAROMETER, MULTI_VALVE,
ANALOG_INPUT, ...). See design §5.17.
Source code in src/alicatlib/errors.py
AlicatParseError ¶
Bases: AlicatProtocolError
A response could not be parsed into its typed model.
Source code in src/alicatlib/errors.py
AlicatProtocolError ¶
Bases: AlicatError
The bytes arrived but did not parse as a valid Alicat response.
Source code in src/alicatlib/errors.py
AlicatSinkDependencyError ¶
Bases: AlicatSinkError, AlicatConfigurationError
A sink's optional backing library is not installed.
Raised when the user instantiates (or calls open() on) a sink
whose extras have not been installed — e.g. ParquetSink without
alicatlib[parquet] or PostgresSink without
alicatlib[postgres]. The message always names the exact extra
to install so the remediation is copy-pasteable.
Multi-inherits :class:AlicatConfigurationError because callers
that already branch on configuration errors (missing extras being
a configuration problem from their perspective) keep working
without changes.
Source code in src/alicatlib/errors.py
AlicatSinkError ¶
Bases: AlicatError
Base class for errors raised by sinks (CSV, JSONL, SQLite, Parquet, Postgres).
Source code in src/alicatlib/errors.py
AlicatSinkSchemaError ¶
Bases: AlicatSinkError
A batch's shape is incompatible with the sink's locked schema.
Raised when a sink has locked its schema on the first batch (or validated against an existing table) and a subsequent batch carries rows whose shape can't be reconciled — for example, a Postgres target table that's missing a required column, or a Parquet writer that would need a type change mid-file.
Dropping unknown optional columns is handled by a per-sink WARN log and does not raise.
Source code in src/alicatlib/errors.py
AlicatSinkWriteError ¶
Bases: AlicatSinkError
The backing store rejected a write.
Wraps the underlying driver exception (sqlite3, asyncpg, pyarrow)
so downstream error handlers don't need to import optional
dependencies. The original exception is preserved via
raise ... from original so tracebacks remain intact.
Source code in src/alicatlib/errors.py
AlicatStreamingModeError ¶
Bases: AlicatProtocolError
A request/response command was attempted while the client was in streaming mode.
Source code in src/alicatlib/errors.py
AlicatTimeoutError ¶
Bases: AlicatTransportError
An I/O timeout expired.
A timeout is never represented as an empty successful response.
Source code in src/alicatlib/errors.py
AlicatTransportError ¶
Bases: AlicatError
Serial/TCP transport failed to move bytes.
Source code in src/alicatlib/errors.py
AlicatUnitIdMismatchError ¶
Bases: AlicatProtocolError
The response's unit ID did not match the request's.
Source code in src/alicatlib/errors.py
AlicatUnsupportedCommandError ¶
Bases: AlicatCapabilityError
The command is not supported on this device kind.
Source code in src/alicatlib/errors.py
AlicatValidationError ¶
Bases: AlicatConfigurationError
Arguments failed validation before any I/O (range checks, missing confirm).
Source code in src/alicatlib/errors.py
DeviceKind ¶
Bases: StrEnum
What kind of Alicat device we're talking to.
Coarser than :class:alicatlib.commands.base.Capability — a flow meter
might or might not have a barometer; a flow controller might have one,
two, or three valves. Per-feature gating is via Capability; this enum
just says "mass-flow meter vs mass-flow controller vs pressure meter ..."
so commands can declare a short list of compatible kinds.
UNKNOWN
class-attribute
instance-attribute
¶
Catch-all for models the factory's MODEL_RULES table doesn't match.
A device with this kind still gets a generic :class:Device facade
(poll() and execute() work); only commands whose
device_kinds explicitly list UNKNOWN will dispatch — the
session's kind-gating (§5.7) rejects the rest. This is the "loud
silence" path: we'd rather tell users "unknown model, try model_hint"
than silently classify a new MFC as a pressure controller.
DeviceResult
dataclass
¶
Per-device result container — value or error, never both.
The union is encoded as two optional fields (rather than an
Either / Result ADT) so mypy's narrowing on ok reads
cleanly at call sites without pattern matching.
Construct via the :meth:success / :meth:failure factories for
cleaner call-site reads; direct keyword construction stays valid
for backwards-compatible test fixtures.
Attributes:
| Name | Type | Description |
|---|---|---|
value |
T | None
|
The successful result, or |
error |
AlicatError | None
|
The captured :class: |
DeviceSnapshot
dataclass
¶
DeviceSnapshot(
name,
model,
firmware,
serial,
connected,
last_error,
recoverable_error_count,
captured_at,
)
Cross-lib status snapshot of a device.
Built by :meth:alicatlib.devices.base.Device.snapshot from cached
identity + session counters — no I/O. Useful for status CLIs,
dashboards, and healthchecks where polling the wire would be
overkill.
Per the cross-lib spec §H every sibling lib ships this base shape;
alicat adds :class:AlicatDeviceSnapshot for media / capabilities.
DiscoveryResult
dataclass
¶
Outcome of a single :func:probe attempt.
Exactly one of :attr:device_info / :attr:error is populated —
ok results carry a fully-identified :class:DeviceInfo, failed
ones carry the typed :class:AlicatError from the identification
pipeline. The :attr:ok convenience lets callers filter without
hasattr.
Shape conforms to the cross-lib spec §B: :attr:address is the
bus address (Alicat unit_id, "A".."Z"), :attr:protocol
names the wire dialect (always :attr:ProtocolKind.ASCII for
alicat), and :attr:elapsed_s measures how long the probe took.
ErrorContext
dataclass
¶
ErrorContext(
command_name=None,
command_bytes=None,
raw_response=None,
unit_id=None,
port=None,
protocol=None,
firmware=None,
device_kind=None,
device_media=None,
command_media=None,
elapsed_s=None,
extra=_empty_extra(),
)
Structured context attached to every :class:AlicatError.
Every field is optional so callers can build a context progressively as a command flows through layers (transport → protocol → session → command).
extra accepts any Mapping and is always frozen into a read-only
:class:types.MappingProxyType at construction. The shared empty
sentinel can therefore never be mutated through
error.context.extra[k] = v.
address
property
¶
Uniform cross-library accessor for the device's bus address.
Alicat's semantically-meaningful native field is :attr:unit_id
(the single-letter A..Z polling id), and that stays the
source of truth inside the library. :attr:address is the
cross-lib spelling consumers can read uniformly across
alicatlib / sartoriuslib / watlowlib / nidaqlib.
merged ¶
Return a new context with updates overlaid. Unknown keys go to extra.
Source code in src/alicatlib/errors.py
ErrorPolicy ¶
Bases: Enum
How the manager surfaces per-device failures.
Under :attr:RAISE, the manager collects every device's result
and — if any call failed — raises an :class:ExceptionGroup
containing the per-device exceptions after the task group joins.
Under :attr:RETURN, each device produces a :class:DeviceResult
and the caller inspects .error per entry.
Design reference: docs/design.md §5.13.
FirmwareVersion
dataclass
¶
Family-scoped firmware version.
Warning — ordering is intentionally family-gated. __lt__ / __le__ /
__gt__ / __ge__ raise :class:TypeError when the operands have
different families. __eq__ returns False on family mismatch rather
than raising (so sets and dict lookups stay well-behaved). This asymmetry
is deliberate: silent cross-family comparison is the worse failure mode.
Canonical gating pattern (see :class:alicatlib.devices.session.Session)::
if cmd.firmware_families and fw.family not in cmd.firmware_families:
raise AlicatFirmwareError(reason="family_not_supported", ...)
if cmd.min_firmware and fw < cmd.min_firmware: # safe: same family
raise AlicatFirmwareError(reason="firmware_too_old", ...)
Attributes:
| Name | Type | Description |
|---|---|---|
family |
FirmwareFamily
|
The firmware family ( |
major |
int
|
Numeric major; |
minor |
int
|
Numeric minor; |
raw |
str
|
The original string as reported by the device, preserved for
diagnostics (e.g. |
parse
classmethod
¶
Parse software into a :class:FirmwareVersion.
Accepts any of the historical shapes: "GP", "GP-10v05",
"1v00", "7v99", "10v05", "10v5", "10.05", or those
substrings embedded in a longer response.
GP detection: if the string contains a standalone GP token, the
family is :attr:FirmwareFamily.GP, regardless of any trailing
Nv<major>v<minor> suffix. major / minor are 0 for GP
(the Nv suffix, when present, is purely cosmetic on GP hardware).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
software
|
str
|
Firmware string as reported by the device. |
required |
Returns:
| Type | Description |
|---|---|
Self
|
The parsed version. |
Raises:
| Type | Description |
|---|---|
AlicatParseError
|
If |
Source code in src/alicatlib/firmware.py
Gas ¶
InvalidUnitIdError ¶
Bases: AlicatConfigurationError
A unit ID was not a single letter A — Z.
Source code in src/alicatlib/errors.py
LoopControlVariable ¶
Bases: IntEnum
Statistics a controller's feedback loop can track.
Values are the primer's statistic codes, so LV <value> over the
wire is a direct str(member.value). The members mirror the
:class:Statistic names they correspond to — LoopControlVariable.MASS_FLOW_SETPT
matches :data:Statistic.MASS_FLOW_SETPT (code 37).
Medium ¶
Bases: Flag
What kind of fluid a device moves.
Orthogonal to :class:~alicatlib.devices.kind.DeviceKind (function
× form). A :class:Flag rather than a plain :class:Enum so the
model can represent devices whose media is ambiguous at the prefix
level — either because the hardware truly supports both (some
Coriolis lines are reported this way) or because the prefix covers
multiple order-time configurations. Gating via bitwise intersection
keeps a single code path for every configuration:
.. code:: python
if not (device.info.media & command.media):
raise AlicatMediumMismatchError(...)
See design §5.9a for the full rationale on modelling medium as a
flag (not an enum), why the class tree stays kind-shaped rather
than medium-shaped, and why assume_media on the factory
replaces rather than unions.
GAS
class-attribute
instance-attribute
¶
Device is configured for gas. Gas-specific commands (GS, ??G*,
gas-mix edits) pass the media gate; liquid-specific commands fail pre-I/O.
LIQUID
class-attribute
instance-attribute
¶
Device is configured for liquid. Liquid-specific commands (fluid select / list, per-fluid reference density) pass the media gate; gas commands fail pre-I/O.
NONE
class-attribute
instance-attribute
¶
No medium resolved. Only valid as an intermediate during identification;
a live :class:~alicatlib.devices.models.DeviceInfo always carries at
least one of :attr:GAS / :attr:LIQUID.
OverflowPolicy ¶
Bases: Enum
What record() does when the receive-stream buffer is full.
The producer runs on an absolute-target schedule; the consumer drains at its own pace. Slow consumers create backpressure — this knob picks how the recorder responds.
BLOCK
class-attribute
instance-attribute
¶
Await the slow consumer. Default. Silent drops are surprising
in a data-acquisition setting, so the recorder blocks the producer
rather than quietly discarding samples. The effective sample rate
drops to the consumer's drain rate; samples_late accrues once
the consumer catches up and the producer can check its schedule.
DROP_NEWEST
class-attribute
instance-attribute
¶
Drop the sample that was about to be enqueued. Counted as late.
DROP_OLDEST
class-attribute
instance-attribute
¶
Evict the oldest queued batch, then enqueue. Counted as late.
PollSource ¶
Bases: Protocol
Minimal shape the recorder needs from its dispatcher.
:class:~alicatlib.manager.AlicatManager satisfies this: its
poll(names) returns a Mapping[str, DeviceResult[Reading]].
Using a Protocol keeps :func:record testable against a lightweight
stub without pulling in the whole manager + transport stack.
poll
async
¶
Poll every named device (or all under management) concurrently.
Must return a mapping keyed by the manager-assigned device name.
Successful polls carry the :class:Reading as .value;
failed ones carry the :class:~alicatlib.errors.AlicatError as
.error (per :class:~alicatlib.manager.ErrorPolicy.RETURN).
Source code in src/alicatlib/streaming/recorder.py
PollSourceAdapter ¶
Wrap one :class:Device as a :class:PollSource for :func:record.
Capa's old _SingleDevicePollSource shim reinvented this; the
adapter lives here so the wiring is one line at the call site::
adapter = PollSourceAdapter("fuel", device)
async with record(adapter, rate_hz=10) as recording:
...
The names filter is honoured per the cross-lib spec §E: when
the caller passes a name set that does not include this device's
name, poll() returns an empty mapping rather than polling
anyway. The recorder always passes a complete name set in
single-device mode so filtering is harmless; the empty-mapping
behaviour is the correct cross-lib semantic.
Source code in src/alicatlib/streaming/__init__.py
poll
async
¶
Poll the wrapped device and return a single-entry mapping.
Source code in src/alicatlib/streaming/__init__.py
ProtocolKind ¶
Bases: Enum
Wire protocol an Alicat library instance speaks.
Alicat devices use a single line-oriented ASCII protocol on every
transport (RS-232 / RS-485 / USB-CDC). The enum exists so the
cross-lib :class:DiscoveryResult / :class:ErrorContext base
fields can carry a typed protocol marker; for alicat the value is
always :attr:ASCII (or None when not applicable).
Reading
dataclass
¶
Timing-wrapped :class:ParsedFrame — the public polling result.
Built by :meth:from_parsed. t_mono_ns is for drift analysis
and scheduling (never wall-clock); received_at is for data
provenance in sinks.
as_dict ¶
Flatten to a JSON/CSV-friendly dict.
Produces {field_name: value, "status": "HLD,OPL", "received_at": iso8601}
— status codes collapse into a single comma-joined sorted string
(empty when no codes are active) so downstream schema is stable
across rows. Callers that need per-code boolean columns should
wrap this themselves; the library picks the schema-stable form.
Source code in src/alicatlib/devices/reading.py
from_parsed
classmethod
¶
Wrap a :class:ParsedFrame with timing captured at read time.
Source code in src/alicatlib/devices/reading.py
get_float ¶
Return the float value at name, or None if absent or non-numeric.
This is the "forgiving" accessor used when a downstream consumer
wants a numeric value and accepts absence. Text-valued fields and
the -- sentinel both yield None; exceptions are never
raised. Callers that need strict behaviour should index
:attr:values directly.
Source code in src/alicatlib/devices/reading.py
get_statistic ¶
Return the value keyed by :class:Statistic, or None if absent.
Prefer this over :meth:get_float when the caller has a typed
:class:Statistic — it's IDE-completable and robust to wire-name
renames across firmware versions.
Source code in src/alicatlib/devices/reading.py
Recording
dataclass
¶
The object yielded by :func:record's async context manager.
Wraps the live receive stream, the (mutable) :class:AcquisitionSummary
the recorder is updating in place, and the rate the recorder is
running at. Consumers iterate via async for batch in recording
(the instance delegates to :attr:stream), observe progress via
:attr:summary, and read :attr:rate_hz for queue-sizing decisions.
Attributes:
| Name | Type | Description |
|---|---|---|
stream |
AsyncIterator[T]
|
Async iterator of per-tick batches (or whatever record
type the lib emits). Typed via |
summary |
AcquisitionSummary
|
Live :class: |
rate_hz |
float
|
The rate the recorder is running at, captured at entry. Useful for back-pressure sizing in wrappers. |
__aiter__ ¶
Delegate iteration to :attr:stream.
Lets async for batch in recording work without forcing
callers to dereference recording.stream themselves —
ergonomic for the common case, while keeping the typed
attribute around for consumers that want to interleave reads
with reading :attr:summary or :attr:rate_hz.
Source code in src/alicatlib/streaming/recorder.py
Sample
dataclass
¶
Sample(
device,
unit_id,
t_mono_ns,
t_utc,
requested_at,
received_at,
latency_s,
reading,
t_midpoint_mono_ns=None,
)
One device poll with full timing provenance.
Attributes:
| Name | Type | Description |
|---|---|---|
device |
str
|
The manager-assigned name (from |
unit_id |
str
|
Bus-level single-letter unit id of the polled device.
Kept separate from |
t_mono_ns |
int
|
:func: |
t_utc |
datetime
|
Wall-clock |
t_midpoint_mono_ns |
int | None
|
Optional monotonic-ns midpoint of an
integration window. |
requested_at |
datetime
|
Wall-clock |
received_at |
datetime
|
Wall-clock |
latency_s |
float
|
|
reading |
Reading
|
The :class: |
Statistic ¶
Unit ¶
UnknownFluidError ¶
Bases: AlicatConfigurationError
A fluid (working-liquid) name or code did not resolve against the registry.
Source code in src/alicatlib/errors.py
UnknownGasError ¶
Bases: AlicatConfigurationError
A gas name or code did not resolve against the registry.
Source code in src/alicatlib/errors.py
UnknownStatisticError ¶
Bases: AlicatConfigurationError
A statistic name or code did not resolve against the registry.
Source code in src/alicatlib/errors.py
UnknownUnitError ¶
Bases: AlicatConfigurationError
A unit name or code did not resolve against the registry.
Source code in src/alicatlib/errors.py
config_from_env ¶
Best-effort env loader.
Only reads well-known keys; unknown keys are ignored. Missing or
unparseable values fall back to :class:AlicatConfig's defaults — this
function never raises. Use explicit dataclass construction when you need
strict validation.
Recognised keys (with prefix="ALICATLIB_"):
ALICATLIB_DEFAULT_TIMEOUT_S— float secondsALICATLIB_MULTILINE_TIMEOUT_S— float secondsALICATLIB_WRITE_TIMEOUT_S— float secondsALICATLIB_DEFAULT_BAUDRATE— intALICATLIB_DRAIN_BEFORE_WRITE—"1"/"true"/"yes"ALICATLIB_SAVE_RATE_WARN_PER_MIN— intALICATLIB_EAGER_TASKS—"1"/"true"/"yes"
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
prefix
|
str
|
Prefix to prepend to each env key. Defaults to |
DEFAULT_ENV_PREFIX
|
Returns:
| Name | Type | Description |
|---|---|---|
An |
AlicatConfig
|
class: |
AlicatConfig
|
unparseable env var. |
Source code in src/alicatlib/config.py
find_devices
async
¶
find_devices(
ports=None,
*,
unit_ids=("A",),
baudrates=DEFAULT_DISCOVERY_BAUDRATES,
timeout=_DEFAULT_PROBE_TIMEOUT_S,
max_concurrency=_DEFAULT_MAX_CONCURRENCY,
stop_on_first_hit=False,
)
Probe the cross-product ports × unit_ids × baudrates concurrently.
When ports is None the sweep enumerates every port visible
via :func:list_serial_ports — convenient for "what's plugged in?"
but note that a large fleet plus multiple baudrates multiplies out
quickly (10 ports × 2 baud × 5 unit ids = 100 probes).
Concurrency is bounded two ways:
max_concurrencyvia :class:anyio.CapacityLimiter— at most that many serial handles are ever open simultaneously.- A per-port :class:
anyio.Lock— combinations targeting the same physical port serialise, because a serial port can only be held by one transport at a time. Without this, a sweep that tries two baud rates on one port would see the second probe fail withPortBusyError(or an unrelated transport error) even when the device is present at the correct baud — the two probes simply raced for the same handle.
Lock order is port-first, limiter-second: a probe waiting on its port lock does not consume a limiter slot, which keeps the overall concurrency ceiling meaningful.
When stop_on_first_hit is True, a successful probe at
(port, _, baud) records baud as that port's confirmed rate
and any pending same-port probe at a different baud is skipped.
Same-baud probes at other unit ids still run (important for RS-485
multi-drop buses where several devices share a port at a single
baud). Skipped combinations are simply omitted from the result
tuple, so the caller can expect len(result) ≤ len(combinations).
Default is False — every combination produces a result, in a
stable row-major order (ports × unit_ids × baudrates).
The function never raises — every probe's result lands in the
returned tuple, ok or not.
Source code in src/alicatlib/devices/discovery.py
214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 | |
list_serial_ports
async
¶
Enumerate serial-port device paths visible to the OS.
Thin wrapper over :func:anyserial.list_serial_ports. Returns
device-path strings (/dev/ttyUSB0, COM3 …) in whatever order
the backend reports.
The native backend does not require the anyserial[discovery-pyserial]
extra; platforms where it misses devices can install that extra and
switch by setting the backend="pyserial" kwarg on
:func:anyserial.list_serial_ports directly.
Source code in src/alicatlib/devices/discovery.py
open_device
async
¶
open_device(
port,
*,
unit_id="A",
serial=None,
timeout=0.5,
recover_from_stream=True,
model_hint=None,
assume_capabilities=Capability.NONE,
assume_media=None,
)
Open and return a fully-identified :class:Device.
Usage forms::
async with await open_device("/dev/ttyUSB0") as device:
...
device = await open_device("/dev/ttyUSB0")
try:
...
finally:
await device.close()
The caller's port determines the lifecycle the device takes
ownership of:
str("/dev/ttyUSB0"etc.) — build a :class:SerialTransportfromserial(or defaults), open it, wrap in an :class:AlicatProtocolClient. The device closes both on :meth:Device.close.- :class:
Transport— wrap in a new :class:AlicatProtocolClient; the transport's open/close is the caller's responsibility (we never close a transport we didn't open). - :class:
AlicatProtocolClient— use as-is; neither transport nor client is closed by the device. Stream recovery is skipped because the factory doesn't have access to the underlying transport.
The assume_capabilities override is union'd onto the probed set
per design §5.9 — the factory never subtracts flags, because
silently masking hardware the device reports as present is exactly
the failure mode capability probing exists to avoid.
The assume_media override replaces the prefix-derived media
(design §5.9a). Medium answers "how is this specific unit
configured," not "what can the hardware do" — the common correction
is to narrow from a permissive prefix default to the single medium
the unit was actually ordered locked to. The K-family CODA prefixes
default to Medium.GAS | Medium.LIQUID because the part-number
decoder encodes kind but not medium; other future order-configurable
prefixes can adopt the same pattern. A replace policy also
future-proofs the model: any new ambiguous prefix drops into
:data:MODEL_RULES with the widest default, and users narrow at
open time.
Source code in src/alicatlib/devices/factory.py
986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 | |
probe
async
¶
Probe one port at one baudrate for one unit id.
Never raises — every failure becomes :attr:DiscoveryResult.error
so that a bulk :func:find_devices call collects a uniform result
set. Opening errors (permission denied, port busy, no such device)
are caught here the same as identification errors; the caller sees
one shape whether the device is offline, misconfigured, or silent.
Source code in src/alicatlib/devices/discovery.py
record
async
¶
record(
source,
*,
rate_hz,
duration=None,
names=None,
overflow=OverflowPolicy.BLOCK,
buffer_size=64,
)
Record polled samples into a receive stream at an absolute cadence.
Usage::
async with record(mgr, rate_hz=10, duration=60) as recording:
async for batch in recording.stream:
process(batch)
print(recording.summary.samples_emitted)
The CM yields a :class:Recording carrying the async iterator, the
live :class:AcquisitionSummary, and the rate. Each batch on the
stream is a Mapping[name, Sample] — one entry per device that
polled successfully on that tick. Devices whose :class:DeviceResult
carries an error are omitted from that batch and logged at WARN.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
source
|
PollSource
|
Any :class: |
required |
rate_hz
|
float
|
Target cadence. Absolute targets are computed
|
required |
duration
|
float | None
|
Total acquisition duration in seconds. |
None
|
names
|
Sequence[str] | None
|
Subset of device names to poll per tick. |
None
|
overflow
|
OverflowPolicy
|
Backpressure policy when the receive-stream buffer
is full. See :class: |
BLOCK
|
buffer_size
|
int
|
Receive-stream capacity, in per-tick batches.
|
64
|
Yields:
| Name | Type | Description |
|---|---|---|
A |
AsyncGenerator[Recording[Mapping[str, Sample]]]
|
class: |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/alicatlib/streaming/recorder.py
194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 | |
sample_to_row ¶
Flatten a :class:Sample into a single row dict for tabular sinks.
Schema layout (stable across samples):
device— manager-assigned name.unit_id— bus-level single-letter id.t_utc/requested_at/received_at— ISO 8601.t_mono_ns— monotonic acquisition midpoint, ns since boot.latency_s— poll round-trip, seconds.- reading fields — everything from :meth:
Reading.as_dictexcept the reading's ownreceived_at(superseded by the sample-level value so all rows have the samereceived_atsemantics). status— comma-joined sorted status codes (empty string when no flags active), from :meth:Reading.as_dict.
The reading's own received_at is dropped so the row's
received_at consistently means "recorder-observed reply time"
across rows — otherwise multi-device rows would mix reading-level
and sample-level timings.
Source code in src/alicatlib/sinks/base.py
to_pint ¶
Return a pint-compatible unit string, or None if unmapped.
Accepts a :class:Unit member, the raw Alicat label string
(case-insensitive fallback), or None. PSIA / PSIG / PSID
collapse to "psi" — lossy by design (spec §K).