Skip to content

Repository files navigation

python-oa3

PyPI versionPython versionsCIRuffLicense: MIT

Python client library for the OpenADR 3 API. Provides Pydantic v2 models with a two-layer coercion pattern (raw JSON shape + snake_case typed entities), an httpx-based API client, and pendulum-powered time types.

Installation

pip install -e ".[dev]"

Dependencies

PackageRole
Pydantic v2Schema validation, model coercion
Pendulum v3DateTime, Duration, timezone handling
httpxHTTP client with auth hooks
openapi-coreOptional OpenAPI spec validation
PyYAMLOpenAPI spec loading

Architecture

Two-Layer Data Model

Every OpenADR 3 entity exists in two forms:

  1. Raw models (openadr3.entities.raw) — Mirror the JSON API shape exactly. CamelCase field aliases, string datetimes, string durations. Useful for serialization and wire-format validation.

  2. Coerced models (openadr3.entities.models) — Snake_case fields, pendulum.DateTime for timestamps, pendulum.Duration for durations, Decimal for PRICE/USAGE payloads. These are what you work with in application code.

JSON API response (camelCase, strings)
│
▼
coerce(raw_dict) ──► Typed entity (snake_case, pendulum, Decimal)
│
└─► ._raw (original dict preserved)

Raw Preservation

Every coerced entity carries its original raw dict as a Pydantic PrivateAttr:

program=openadr3.coerce(api_response)
program.program_name# "My DR Program"program.created# DateTime(2024, 6, 15, 10, 0, 0, tzinfo=UTC)program._raw["programName"] # "My DR Program" (original wire format)

Entity Dispatch

The coerce() function dispatches on the objectType string in the raw dict:

fromopenadr3importcoerceraw= {"objectType": "PROGRAM", "programName": "Test", ...}
program=coerce(raw) # Returns a Program instanceraw= {"objectType": "EVENT", "programID": "p1", ...}
event=coerce(raw) # Returns an Event instance

Handles request variants too: BL_VEN_REQUEST and VEN_VEN_REQUEST coerce as Ven, BL_RESOURCE_REQUEST and VEN_RESOURCE_REQUEST coerce as Resource.

Notification Coercion

coerce_notification() handles both spec-compliant camelCase notifications and the snake_case format currently sent by the VTN Reference Implementation:

fromopenadr3importcoerce_notification, is_notification# Spec-compliant (camelCase)webhook_payload= {
"objectType": "EVENT",
"operation": "CREATE",
"object": {"programID": "prog-001", "eventName": "Peak Event", ...}
}
# VTN-RI (snake_case) — see oadr3-org/openadr3-vtn-reference-implementation#181mqtt_payload= {
"object_type": "EVENT",
"operation": "CREATE",
"object": {"program_id": "prog-001", "event_name": "Peak Event", ...}
}
# Both formats workforpayloadin [webhook_payload, mqtt_payload]:
ifis_notification(payload):
notification=coerce_notification(payload)
notification.object# Coerced Event instance

Entity Types

EntityKey FieldsNotes
Programprogram_name, interval_period, descriptions, payload_descriptors, attributes, targetsTop-level DR program
Eventprogram_id, event_name, duration, priority, intervals, targetsDR event with signal intervals
Venven_name, client_id, attributes, targetsVirtual End Node
Resourceresource_name, ven_id, client_id, attributes, targetsDevice/load under a VEN
Reportevent_id, client_name, resourcesVEN telemetry report
Subscriptionclient_name, object_operations, program_id, targetsWebhook/MQTT subscription
Notificationobject_type, operation, objectPush notification wrapper

All top-level entities share common metadata: id, created (DateTime), modified (DateTime), object_type.

Supporting Types

TypeDescription
IntervalPeriodStart datetime + duration + computed period tuple
IntervalNumbered interval with payloads
PayloadType-tagged values (PRICE/USAGE get Decimal coercion)
EventPayloadDescriptorEvent payload descriptor (payloadType, units, currency)
ReportPayloadDescriptorReport payload descriptor (payloadType, readingType, units, accuracy, confidence)
ObjectOperationSubscription callback definition

Event.resolved_intervals()

The OpenADR 3.1.0 spec says event.intervalPeriod"sets default start time and duration of intervals", and a per-interval intervalPeriod"may set temporal aspects of interval or override event.intervalPeriod". The raw Event.intervals[].interval_period field is left untouched (raw passthrough) — call event.resolved_intervals() to get a new list of Interval objects with this inheritance applied.

event=client.event("evt-001")
forintervalinevent.resolved_intervals():
start, end=interval.interval_period.period# ...act on the resolved wall-clock window

Inheritance rules: per-interval duration falls back to event.interval_period.duration; per-interval start falls back to event.interval_period.start advanced by the sum of all prior intervals' resolved durations (so interval i with no per-interval start lands at event.start + i × event.duration when every interval inherits). randomize_start is per-interval only — there is no event-level fallback. The original Event.intervals list is never mutated.

API Client

Quick Start

importopenadr3# Create a VEN clientclient=openadr3.create_ven_client(
base_url="https://vtn.example.com/openadr3/3.1.0",
token="your-bearer-token",
spec_path="resources/openadr3.yaml", # optional, for route introspection
)
# Coerced entity methods — returns typed modelsprograms=client.programs()
event=client.event("evt-001")
print(event.program_id) # "prog-001"print(event.created) # DateTime(2024, 6, 15, ...)print(event._raw) # Original API dict# Raw HTTP methods — returns httpx.Responseresp=client.get_events(programID="prog-001")
ifopenadr3.success(resp):
data=openadr3.body(resp)

Client Types

# VEN client — scopes: read_all, read_targets, read_ven_objects,# write_reports, write_subscriptions, write_vensven=openadr3.create_ven_client(base_url, token)
# Business Logic client — scopes: read_all, read_bl,# write_programs, write_events, write_subscriptions, write_vensbl=openadr3.create_bl_client(base_url, token)
# Custom clientclient=openadr3.OpenADRClient(
base_url="https://vtn.example.com/openadr3/3.1.0",
token="tok",
spec_path="resources/openadr3.yaml",
client_type="custom",
scopes=frozenset({"read_all", "write_events"}),
)

Available Methods

Coerced (return entity models):

MethodReturns
client.programs()list[Program]
client.program(id)Program
client.events()list[Event]
client.event(id)Event
client.vens()list[Ven]
client.ven(id)Ven
client.resources()list[Resource]
client.resource(id)Resource
client.reports()list[Report]
client.report(id)Report
client.subscriptions()list[Subscription]
client.subscription(id)Subscription
client.find_program_by_name(name)Program | None
client.find_ven_by_name(name)Ven | None

Raw (return httpx.Response):

Each entity has: get_<entities>(), get_<entity>_by_id(id), create_<entity>(data), update_<entity>(id, data), delete_<entity>(id).

Introspection (requires spec_path):

client.all_routes() # ["/programs", "/programs/{programID}", ...]client.endpoint_scopes("/programs", "get") # ["read_all"]client.authorized("/events", "post") # True/False based on client scopes

User-Agent

Every client sends a User-Agent header for server-side log identification. The default is openadr3/<version> (node=<hex>) where the node ID comes from uuid.getnode() (MAC-derived, stable across restarts).

Override it to identify your application:

client=openadr3.create_ven_client(
base_url=base_url,
token=token,
user_agent="my-ven-app/1.0",
)
# Or compose a layered string with the defaultfromopenadr3.apiimportDEFAULT_USER_AGENTclient=openadr3.OpenADRClient(
base_url=base_url,
user_agent=f"my-app/1.0 {DEFAULT_USER_AGENT}",
)

The User-Agent is preserved across fetch_token() client recreation.

Context Manager

withopenadr3.create_ven_client(base_url, token) asclient:
programs=client.programs()

Authentication

fromopenadr3importBearerAuth, fetch_token# Fetch an OAuth2 tokentoken=fetch_token(
base_url="https://vtn.example.com/openadr3/3.1.0",
client_id="my-client",
client_secret="secret",
scopes=["read_all", "write_reports"],
)
# Use BearerAuth directly with httpxauth=BearerAuth(token)

Time and Timezones

Datetimes are zone-aware end-to-end. The library treats the wire string's offset as the source of truth and preserves it through parse → serialize without normalization.

Wire stringRound-trip outputNotes
2024-06-15T10:30:00Z2024-06-15T10:30:00ZUTC literal preserved
2024-06-15T10:30:00+00:002024-06-15T10:30:00+00:00Not normalized to Z
2024-06-15T10:30:00-07:002024-06-15T10:30:00-07:00Negative offsets preserved
2024-06-15T10:30:00+05:302024-06-15T10:30:00+05:30Half-hour offsets preserved
2024-06-15T10:30:00.123456Z2024-06-15T10:30:00.123456ZSub-second precision preserved

This holds at both the parse_datetime / .to_iso8601_string() level and end-to-end through Pydantic models that use the PendulumDateTime annotated type. Round-trip behavior is covered by the test suite (tests/test_time.py::TestWireOffsetPreservation, TestPydanticAnnotatedRoundTrip).

Parsing and conversion

fromopenadr3importparse_datetime, parse_duration, to_zoned# Parse datetimes (handles VTN-RI non-standard formats)dt=parse_datetime("2024-06-15T14:00:00Z")
dt=parse_datetime("2024-06-15 14:00:00Z") # space instead of T# Parse ISO 8601 durationsdur=parse_duration("PT2H30M")
# Convert to a named timezone (returns a new pendulum.DateTime)eastern=to_zoned(dt, "America/New_York")

to_zoned is the only operation that intentionally changes the wire offset — it's an explicit conversion, not a normalization on parse.

Pydantic Annotated Types

PendulumDateTime and PendulumDuration are Annotated types with BeforeValidator and PlainSerializer, ready for use in your own Pydantic models. Pendulum types require arbitrary_types_allowed=True in the model config:

frompydanticimportBaseModel, ConfigDictfromopenadr3importPendulumDateTime, PendulumDurationclassMyModel(BaseModel):
model_config=ConfigDict(arbitrary_types_allowed=True)
start: PendulumDateTime=Nonelength: PendulumDuration=None

Enums

fromopenadr3importObjectType, Operation, PayloadTypeObjectType.PROGRAM# "PROGRAM"Operation.CREATE# "CREATE"PayloadType.PRICE# "PRICE"

Payload Coercion

Payload values are dispatched by type string:

Payload TypeCoercion
PRICEValues become Decimal; type preserved as sent
USAGEValues become Decimal; type preserved as sent
All othersValues pass through; type preserved as sent

The wire type string is preserved exactly as the VTN sent it (typically UPPER_SNAKE_CASE per the OA3 spec: PRICE, EXPORT_PRICE, GHG, USAGE, …). This matches the descriptor-side payload_type field, so consumers can join interval payload rows against payload_descriptors[].payload_type by direct string equality. (Behavior changed in 0.4.0 — prior versions lowercased the type; see CHANGELOG.md.)

The registry is extensible — add entries to openadr3.entities.payloads._PAYLOAD_REGISTRY.

Module Structure

src/openadr3/
├── __init__.py # Public API re-exports
├── py.typed # PEP 561 type stub marker
├── time.py # Pendulum parsing, annotated types
├── enums.py # ObjectType, Operation, PayloadType
├── auth.py # BearerAuth, OAuth2 token fetch
├── api.py # OpenADRClient, create_ven_client, create_bl_client
└── entities/
├── __init__.py # coerce(), coerce_notification(), is_notification()
├── models.py # Coerced Pydantic models (snake_case, pendulum)
├── raw.py # Raw Pydantic models (camelCase, strings)
└── payloads.py # Payload type dispatch (PRICE/USAGE → Decimal)

Development

# Install with dev dependencies
pip install -e ".[dev]"# Run tests
pytest tests/ -v
# Lint and format
ruff check src/
ruff format --check src/
# Type check (py.typed marker included)
mypy src/openadr3/

Pre-commit Hooks

This project uses pre-commit to run Ruff lint and format checks automatically:

pip install pre-commit
pre-commit install

Ruff lint + format are also enforced in CI via .github/workflows/ci.yml.

OpenAPI Spec

The OpenADR 3.1.0 specification is embedded at resources/openadr3.yaml. See resources/ORIGIN.md for provenance and license.

Changelog

Release history and behavioral changes are tracked in CHANGELOG.md.

Contributing

Issues, Discussions, and pull requests are welcome — see CONTRIBUTING.md for the workflow (and the dev commands: tests, lint, format, type check, pre-commit). In short:

  • Questions, API/design discussion, OpenADR spec or VTN behavior gapsDiscussions
  • Confirmed bugs, coercion/schema fixes, doc errorsIssues
  • Patches → pull requests; please open a Discussion or Issue first for non-trivial changes (new entity types, new endpoints, new schema fields, new payload dispatch behavior)

License

MIT License — Copyright (c) 2026 Clark Communications Corporation

About

OpenADR 3 Python entity API library — Pydantic models, pendulum time types, httpx client

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages