Skip to main content

OpenADR 3 Entity API Library

Project description

python-oa3

PyPI version Python versions CI Ruff License: 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

Package Role
Pydantic v2 Schema validation, model coercion
Pendulum v3 DateTime, Duration, timezone handling
httpx HTTP client with auth hooks
openapi-core Optional OpenAPI spec validation
PyYAML OpenAPI 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:

from openadr3 import coerce

raw = {"objectType": "PROGRAM", "programName": "Test", ...}
program = coerce(raw)  # Returns a Program instance

raw = {"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:

from openadr3 import coerce_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#181
mqtt_payload = {
    "object_type": "EVENT",
    "operation": "CREATE",
    "object": {"program_id": "prog-001", "event_name": "Peak Event", ...}
}

# Both formats work
for payload in [webhook_payload, mqtt_payload]:
    if is_notification(payload):
        notification = coerce_notification(payload)
        notification.object  # Coerced Event instance

Entity Types

Entity Key Fields Notes
Program program_name, interval_period, descriptions, payload_descriptors, attributes, targets Top-level DR program
Event program_id, event_name, duration, priority, intervals, targets DR event with signal intervals
Ven ven_name, client_id, attributes, targets Virtual End Node
Resource resource_name, ven_id, client_id, attributes, targets Device/load under a VEN
Report event_id, client_name, resources VEN telemetry report
Subscription client_name, object_operations, program_id, targets Webhook/MQTT subscription
Notification object_type, operation, object Push notification wrapper

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

Supporting Types

Type Description
IntervalPeriod Start datetime + duration + computed period tuple
Interval Numbered interval with payloads
Payload Type-tagged values (PRICE/USAGE get Decimal coercion)
EventPayloadDescriptor Event payload descriptor (payloadType, units, currency)
ReportPayloadDescriptor Report payload descriptor (payloadType, readingType, units, accuracy, confidence)
ObjectOperation Subscription callback definition

API Client

Quick Start

import openadr3

# Create a VEN client
client = 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 models
programs = 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.Response
resp = client.get_events(programID="prog-001")
if openadr3.success(resp):
    data = openadr3.body(resp)

Client Types

# VEN client — scopes: read_all, read_targets, read_ven_objects,
#              write_reports, write_subscriptions, write_vens
ven = openadr3.create_ven_client(base_url, token)

# Business Logic client — scopes: read_all, read_bl,
#              write_programs, write_events, write_subscriptions, write_vens
bl = openadr3.create_bl_client(base_url, token)

# Custom client
client = 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):

Method Returns
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 default
from openadr3.api import DEFAULT_USER_AGENT
client = 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

with openadr3.create_ven_client(base_url, token) as client:
    programs = client.programs()

Authentication

from openadr3 import BearerAuth, fetch_token

# Fetch an OAuth2 token
token = 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 httpx
auth = 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 string Round-trip output Notes
2024-06-15T10:30:00Z 2024-06-15T10:30:00Z UTC literal preserved
2024-06-15T10:30:00+00:00 2024-06-15T10:30:00+00:00 Not normalized to Z
2024-06-15T10:30:00-07:00 2024-06-15T10:30:00-07:00 Negative offsets preserved
2024-06-15T10:30:00+05:30 2024-06-15T10:30:00+05:30 Half-hour offsets preserved
2024-06-15T10:30:00.123456Z 2024-06-15T10:30:00.123456Z Sub-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

from openadr3 import parse_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 durations
dur = 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:

from pydantic import BaseModel, ConfigDict
from openadr3 import PendulumDateTime, PendulumDuration

class MyModel(BaseModel):
    model_config = ConfigDict(arbitrary_types_allowed=True)
    start: PendulumDateTime = None
    length: PendulumDuration = None

Enums

from openadr3 import ObjectType, Operation, PayloadType

ObjectType.PROGRAM   # "PROGRAM"
Operation.CREATE     # "CREATE"
PayloadType.PRICE    # "PRICE"

Payload Coercion

Payload values are dispatched by type string:

Payload Type Coercion
PRICE Values become Decimal; type preserved as sent
USAGE Values become Decimal; type preserved as sent
All others Values 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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

openadr3-0.4.0.tar.gz (38.5 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

openadr3-0.4.0-py3-none-any.whl (19.9 kB view details)

Uploaded Python 3

File details

Details for the file openadr3-0.4.0.tar.gz.

File metadata

  • Download URL: openadr3-0.4.0.tar.gz
  • Upload date:
  • Size: 38.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for openadr3-0.4.0.tar.gz
Algorithm Hash digest
SHA256 f53bc3feec5bd304c1d886366e2ebe90ff075bb6d13e978e0110a7576370e492
MD5 e0d590b3bbeec7d9b8dabf4b2f57b7cd
BLAKE2b-256 126810d3bfe5ca9386d9068534fd80d9d485cbe565b65944e56fa2dde5efb9d2

See more details on using hashes here.

Provenance

The following attestation bundles were made for openadr3-0.4.0.tar.gz:

Publisher: publish.yml on grid-coordination/python-oa3

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file openadr3-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: openadr3-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 19.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for openadr3-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 6f8ae15ea8cad4533fb2b78e0f47ca51c13002a5f6c92af9d8af725c95b27eae
MD5 c5cedfadc0457314edbcc8da32515e09
BLAKE2b-256 3ad1e39f9d73269c6cbb6f66ddc925331025480585edf067298e907141e22dab

See more details on using hashes here.

Provenance

The following attestation bundles were made for openadr3-0.4.0-py3-none-any.whl:

Publisher: publish.yml on grid-coordination/python-oa3

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page