Skip to content

Extension Framework

Gridalyn is an analysis and integration framework: components from outside the gridalyn codebase can be registered to serve a role contract without editing the SDK. The governing principle is discoverable but never silent — an extension that participates in a run is declared, versioned and recorded in provenance.

This page documents the foundation. The generic engine lives in gridalyn/foundation/platform/extensions.py; the six per-role registries (power-flow backend, surrogate, policy, channel model, observation producer, network adapter) are open to external registration, each through a public register_<role>_extension host API. A semantic capability can also arrive from an extension a study declares — see Contributing a semantic capability.

Try it

Scaffold a real extension package before reading the contract it satisfies:

gridalyn extension new acme_backend --role powerflow_backend --target /tmp/ext-demo
scaffolded extension 'acme_backend' at /tmp/ext-demo/acme_backend
next: install the package, then run `gridalyn extension validate acme_backend`

Three files land under /tmp/ext-demo/acme_backend/: acme_backend.py (the module — descriptor + factory), pyproject.toml (the entry-point wiring), test_acme_backend.py (the conformance smoke test). The rest of this page explains what each of those three files is for.

What an extension is

An extension is a component that conforms to a role contract and carries an ExtensionDescriptor:

  • extension_id — stable ID the extension resolves by (explicit, never discovered ambiently).
  • role — the contract the extension serves (e.g. powerflow_backend). The engine treats it as data; role semantics belong to the caller.
  • name, version — human-readable identity, recorded in provenance.
  • contract_version — the role-contract version the extension conforms to. The engine refuses a descriptor whose version it does not support; there is no silent fallback.
  • source — where the registration came from: core (shipped in gridalyn), host (registered at runtime by the embedding application), or entry_point (discovered from a declared entry point / namespace walk).
  • entry_point_group, module_hash — discovery and content pinning when known.

The generic ExtensionRegistry stores descriptors plus factories keyed by extension_id and knows nothing about roles. It exposes register, get_descriptor, list_descriptors and resolve with located, remediating errors.

Registration sources

Source How Provenance
core gridalyn's own shipped defaults (unchanged behaviour) source: "core"
host register_extension(factory, descriptor=...) from the embedding application's entry script or notebook source: "host"
entry_point declared gridalyn.extensions entry-point group / namespace walk, loaded on demand source: "entry_point", version, module_hash

register_extension is the public host API: a third-party component conforms to a role contract, builds an ExtensionDescriptor, and registers it — no edit to the gridalyn codebase required.

Per-role host surface

Each shipped role has a registry, a role-specific descriptor, and a public register_<role>_extension convenience that mirrors register_extension and routes into the role's default registry (a cached singleton, so a registration made through the host API stays resolvable through the default path). Every role descriptor carries contract_version and every registry rejects an unsupported version at registration with a located UnsupportedContractVersionError naming the supported versions — there is no silent fallback, so an extension that participates is declared, versioned and never silent.

Role Registry Descriptor Host registration API contract_version
Power-flow backend PowerFlowBackendRegistry PowerFlowBackendDescriptor register_powerflow_backend_extension "1"
Surrogate SurrogateRegistry SurrogateDescriptor register_surrogate_extension "1"
Voltage-control policy PolicyRegistry PolicyDescriptor register_policy_extension "1"
Channel model ChannelModelRegistry ChannelModelDescriptor register_channel_model_extension "1"
Observation producer ObservationProducerRegistry ObservationProducerDescriptor register_observation_producer_extension "1"
Network adapter NetworkAdapterRegistry NetworkAdapterDescriptor register_network_adapter_extension "1"

The observation-producer convenience takes the producer callable itself rather than a factory — producers are functions with nothing to instantiate. Each convenience accepts an optional registry= argument to target a specific registry instance (defaults to the role's shared default registry); all six conveniences are exported from the layer facades (gridalyn.simulation for backend/surrogate/policy/channel model, gridalyn.twin for producer/adapter).

The role descriptors that embed in run manifests expose as_dict() with a JSON-native shape that includes contract_version; the twin descriptors (producer, adapter) are metadata-only and have no as_dict().

Discovery & Capabilities

The entry_point source is wired: a package ships an extension by declaring an entry point in the gridalyn.extensions group, and gridalyn sees it without loading it. Awareness and resolution are deliberately separate operations:

  • Awareness — gridalyn extension list. Walks the entry-point group and reports every installed extension (extension_id, version, contract version, source) without importing any module. list_entry_point_metadata is the stdlib primitive behind it.
  • Resolution — declared-only. load_entry_point_extensions(group, declared_ids) imports only the IDs a caller names. Ambient entries are never loaded. An extension module exposes a callable factory and an ExtensionDescriptor descriptor; the loader stamps source="entry_point", the entry_point_group, and a content module_hash, and registers into the default registry. An undeclared ID is a located error naming what is installed; a module that does not follow the convention is a located ImportError.
  • gridalyn extension validate ID... loads the declared IDs and reports their provenance facts, exiting non-zero on any failure. If an extension declares REQUIRED_CAPABILITIES that its environment cannot meet, it is surfaced as MissingCapabilityError — registered but not ready is never silent.

A project declares which extensions its runs resolve through spec.inputs.extensions in project.yaml — bare IDs in the default gridalyn.extensions group, or {id, group} mappings. load_declared_extensions and resolve_declared_extensions (in gridalyn.projects.model_inputs) read and load that declaration, and since bd 4ky.8 the runner acts on it: before any stage runs, register_declared_extensions (in gridalyn.projects.extension_roles) loads the declared extensions and routes each to the registry of the role it serves, so a declaration that cannot be honoured fails the run up front. A project that declares nothing loads nothing, so its governed behavior stays unchanged; the only manifest change any run sees is the always-present empty extensions: [] entry (a deliberate additive-key re-base — see the run-provenance docs).

Extensible capabilities. The core capability set (geo, sim, ops — truly-optional modules in OPTIONAL_CAPABILITY_MODULES) stays fixed. An external package may declare NEW capability keys through the gridalyn.capabilities entry-point group: its module exposes CAPABILITY_MODULES, a dict shaped like the core map. require_capabilities merges those declarations additively — an extra may only add new capabilities, never redefine the core set, and never an empty (always-green) one. The capability contract test validates this external format.

Contributing a semantic capability

An extension whose descriptor declares role="semantic_capability" contributes a semantic capability: its factory returns a gridalyn.twin.semantic.vocabulary.SemanticCapability — namespaces, semantic types, relationships with their axioms, and an emitter. A study that lists the extension in spec.inputs.extensions can build a semantic graph with that capability exactly as it would with one gridalyn ships.

  • Registered where the build happens. The runner registers a study's declared extensions before its first stage. A stage runs as its own process and inherits none of that, so a stage that builds calls script.resolve_extensions() first — a no-op for a study that declares nothing. Registering the same extension twice in one process is a no-op; a capability ID already held by a different source or version is refused, because one ID names one declaration.
  • Checked, not trusted. A factory that returns anything but a SemanticCapability is a located TypeError. The capability's emitter is held to the same profile as a shipped one: its types and predicates must be declared, and the validator checks domain, range and cardinality.
  • Declared-only. An extension that is installed but not declared is never loaded, so a build that asks for its capability fails with UnknownSemanticCapabilityError, naming the capabilities that are registered — loudly, not silently.
  • Interaction protocols are not open. An extension declaring role="interaction_protocol" is refused. The protocol set in gridalyn/operations/interaction/conversations.py is closed by design: a conversation's legality must not depend on what happens to be installed.
  • Other roles keep today's behaviour: they stay in the generic registry and are recorded in provenance.extensions.

A complete, runnable example ships at examples/extensions/feeder_criticality/: an extension that adds a criticality assessment to every distribution transformer, and a study at examples/extensions/feeder_criticality/study/ that declares it (spec.inputs.extensions: [feeder_criticality]) and builds its graph with it. examples/extensions/feeder_criticality/run_example.py exposes the extension's entry point the way an installation does and runs the study for real; with --without-declaration, the same study fails its build. tests/test_extension_semantic_capability.py pins both.

Authoring an extension

Authoring is first-class: gridalyn extension new <name> [--role ROLE] [--target DIR] scaffolds a conformant extension package into DIR/<name>/ (defaulting to the current directory). The scaffold writes three files:

  • <name>.py — the extension module. It exposes the two attributes the loader requires: descriptor, an ExtensionDescriptor instance declaring extension_id, role, name, version and contract_version; and factory, a callable returning the role's component. When the extension needs optional capabilities, the module also declares REQUIRED_CAPABILITIES (a tuple of capability names such as ("sim",)).
  • pyproject.toml — wires the entry point: under [project.entry-points."gridalyn.extensions"], the line <name> = "<module>" (module-only value; the loader reads the module and finds factory/descriptor inside it).
  • test_<name>.py — a smoke test asserting the descriptor is conformant (contract_version in SUPPORTED_CONTRACT_VERSIONS, factory callable).

For example, gridalyn extension new acme_backend --role powerflow_backend produces a module whose descriptor has extension_id="acme_backend", role="powerflow_backend", contract_version="1", and a factory returning a placeholder the author replaces with a real component. --force overwrites an existing directory; a name containing path separators is refused with a located error.

The engine is not modified by any of this: scaffold_extension only writes files that already conform to the module convention, and the loader is the same one that resolves hand-written extensions.

A complete, committed example is shipped at examples/extensions/hello_world/ (scaffolded with gridalyn extension new hello_world --role data_source); its provenance note (scaffold.yaml) records the exact command that produced it, so the example is auditable and reproducible.

Validating an extension

After installing the package (so its entry point is visible to importlib.metadata), check the two sides of the loop:

  • gridalyn extension list — awareness: reports the installed extension (extension_id, version, contract version, source) without importing it.
  • gridalyn extension validate <id> — resolution: loads exactly that ID through the declared-only path, reports its provenance facts, and exits non-zero if the ID is undeclared, unimportable, or registered but not ready (its REQUIRED_CAPABILITIES cannot be met on the current install — surfaced as MissingCapabilityError). Registered but not ready is never silent.

Provenance

provenance.extensions is a JSON-native snapshot of the extensions in the generic engine's DEFAULT_REGISTRY (id, role, name, version, contract version, source, entry-point group, module hash), sorted by extension_id — populated whenever an extension is registered (host) or loaded (entry point) into the runner's process before the manifest is written — since bd 4ky.8 that includes every extension a study declares, which the runner registers before it writes the manifest. Role-level provenance records which extension served a role: provenance.powerflow_backend carries extension_id/extension_source/extension_version when the resolved backend is an external extension (source != "core"), and provenance.channel_model carries the same three keys when a study declares a channel model served by an extension. The remaining roles will reach the manifest the same way in a future release. A plugin may be discoverable, but it is never silent.

Compatibility

SUPPORTED_CONTRACT_VERSIONS is the guard: a descriptor whose contract_version is not supported is rejected at registration with a located error naming the supported versions. This keeps an incompatible extension from changing results without appearing correctly in provenance.

Design

The full architecture and the EMFlow-inspired discovery model live in the internal design exploration (planning documents are not shipped with the package). The engine itself is stdlib-only so foundation remains the bottom layer with no upward imports.