Twin
What problem this layer solves
Every layer above this one — assets, simulation, operations — needs one
trustworthy answer to "what does the grid look like right now, and where did
that answer come from." twin is that answer: a canonical network model with
a declared schema, a stamped identity, and a contract for how observed state
enters it. It does not run power flow and it does not model buildings; it
holds the topology and state that everything else reasons about.
The name is aspirational, and this page says so on purpose. Under
Kritzinger's digital-twin taxonomy — a classification of digital
model / digital shadow / digital twin by how data moves between the
physical and digital sides, not by how detailed either one is — the
classes are separated by automated data flow, not by fidelity. What gridalyn.twin ships today is a canonical,
identified, schema-declared digital model with a one-way, automated
measured-state ingest path: physical → digital only. A deployment
becomes a digital shadow the moment its operator feeds it real measured
data through that path — the SDK itself never claims to be one, because both
producers it exercises in CI remain simulated-or-fixture. Bidirectional flow
(digital → physical control) is a recorded non-goal. Never write that this
layer "is a digital shadow" unqualified.
The vocabulary
- The base — five canonical tables (
buildings,grid_buses,grid_lines,grid_transformers,building_grid_connectivity), each with a schema declared intwin/network/schema.py(BASE_TABLE_SCHEMAS) rather than guessed at read time from whatever columns happen to be present. An absent artifact fails loudly; it does not silently validate as intact. ModelIdentity— three fields that carry real CGMESFullModelheader semantics (idfrom the model's content digest,createdfrom the manifest,profilefrom the declared profile id), and two fields deliberately not claimed as CGMES mappings because the values they hold don't honor those fields' meaning (artifact_paths,governance_schema_version) — named for what they actually are rather than for a CGMES field they only resemble.ModelAuthoritySet/ModelProfile(twin/adapters/authority.py) — the CGMES Model Authority Set and profile pattern expressed as fields and rules over parquet, never as RDF/XML serialization. A profile's dependencies are derived fromBASE_TABLE_SCHEMAS's column references, not hand-declared, so they cannot drift out of sync with the schema.NetworkObservation— a reading of network state with a requiredprovenance: Literal["simulated", "measured"]field and anas_ofinstant. Two producers resolve by explicit ID throughtwin/observation/registry.py, never byentry_pointsauto-discovery:
| Producer ID | Provenance | Reads |
|---|---|---|
powerflow |
simulated |
A solved network's res_bus/res_line result tables; one observation per solved operating point |
measured-ingest |
measured |
Tidy (timestamp, entity_id, quantity, value) rows against a declared EntityJoin; as_of stamped from the datum — naive timestamps are rejected, never silently localized |
flowchart LR
subgraph SIM["provenance = simulated"]
direction TB
A["a solved network<br/>res_bus · res_line"] --> B["powerflow"]
end
subgraph MEA["provenance = measured"]
direction TB
C["tidy rows<br/>timestamp · entity_id · quantity · value"] --> D["measured-ingest"]
end
B --> O["NetworkObservation<br/>provenance required · as_of"]
D --> O
O --> U["assets · simulation · operations"]
classDef sim fill:#e0f2f1,stroke:#00897b,color:#004d40
classDef mea fill:#fff3e0,stroke:#ef6c00,color:#e65100,stroke-width:2px
classDef contract fill:#e8eaf6,stroke:#3f51b5,color:#1a237e
class A,B sim
class C,D mea
class O,U contract
The right-hand lane is the only one that changes a deployment's Kritzinger
class: feeding real measured data through measured-ingest is what makes a
deployment a digital shadow. Both producers CI exercises are
simulated-or-fixture, which is why the SDK itself never claims to be one.
The contract
A declared schema is a promise, not a suggestion: validate_authority_partition
runs at the top of every producer's load_snapshot() and checks that the
declared authority sets actually partition the canonical artifacts — no table
claimed twice, none left unclaimed. Reading the base through
NetworkModelRepository means every table comes back schema-validated, with
row counts and a SHA-256 per artifact recorded for provenance, not a bag of
whatever columns a producer happened to write.
Which state a snapshot is read as. A snapshot's operational state is
declared, never inferred from its contents: base, normal, current,
planned or study_case. NetworkModelRepository resolves exactly one of them
for every model it loads, in a fixed order of authority — an explicit
operational_state= passed to the repository wins; failing that, the
operational_state recorded in the snapshot's metadata.json; failing that,
base — the state a snapshot reads as when neither the caller nor the manifest
declares one. A non-base state reaches disk exactly one way, through
write_base_metadata(..., operational_state=...); no production adapter passes
it yet, so the manifests they write record no state at all. That absence is not
an error: a manifest carrying no such key — written before the key existed, or
by a producer never told which state it is exporting — loads as base rather
than failing, so an older base snapshot keeps reading with no call-site change.
A manifest recording anything outside those five is rejected, naming the
manifest path and the valid set, rather than quietly degraded to base; the
same set is enforced at the writer, so an unloadable state cannot reach a file
in the first place. The state belongs to a repository's reading of a
snapshot, not to the tables themselves: a NetworkModel a source adapter
builds directly in memory carries operational_state of None, because
nothing has declared which state it represents.
flowchart TB
START(["NetworkModelRepository.load_model"])
Q1{"operational_state=<br/>passed to the repository?"}
Q2{"operational_state recorded<br/>in metadata.json?"}
A1["that state"]
A2["that state"]
A3["base"]
BAD["ValueError naming the manifest path<br/>and the five valid states"]
START --> Q1
Q1 -->|yes| A1
Q1 -->|no| Q2
Q2 -->|"present and one of the five"| A2
Q2 -->|"present, outside the five"| BAD
Q2 -->|absent| A3
classDef ask fill:#e8eaf6,stroke:#3f51b5,color:#1a237e
classDef ok fill:#e0f2f1,stroke:#00897b,color:#004d40
classDef fallback fill:#fff3e0,stroke:#ef6c00,color:#e65100
classDef bad fill:#ffebee,stroke:#c62828,color:#b71c1c
class Q1,Q2 ask
class A1,A2 ok
class A3 fallback
class BAD bad
The five states are base, normal, current, planned and study_case.
Note which branch is not an error: an absent key loads as base, so an
older snapshot keeps reading with no call-site change — only a key holding
something outside the five is rejected.
Why no rdflib. CGMES semantics are adopted as fields and rules, never
as serialization. rdflib is not a dependency of this repository — not in
pyproject.toml, in any extra, and real imports of it under gridalyn/ are
pinned at zero by an AST scan. Its only historical consumer was a dead RDF/XML
exporter with no importers and no tests, removed with the evidence recorded.
Re-adding it to serialize the twin would be a regression, not a feature.
Using it
Reading the committed base and its identity:
from pathlib import Path
from gridalyn.twin import NetworkModelRepository
repository = NetworkModelRepository(Path("instances/default/digital_twin/base"))
model = repository.load_model()
print(len(model.buildings), "buildings")
print(model.identity.id[:16], model.identity.created)
Producing an observation — the two producers share one contract, so calling code does not need to know which one it is talking to beyond the ID:
from gridalyn.twin.observation.registry import default_observation_producer_registry
registry = default_observation_producer_registry()
for descriptor in registry.list_descriptors():
print(descriptor.producer_id, descriptor.provenance, descriptor.summary)
Verifying it
python3 -c "
from gridalyn.twin.network.schema import BASE_TABLE_SCHEMAS
print(sorted(BASE_TABLE_SCHEMAS))"
python3 -c "
from gridalyn.twin.observation.registry import default_observation_producer_registry
for d in default_observation_producer_registry().list_descriptors():
print(d.producer_id, '->', d.provenance)"
Both outputs above were produced by running these exact commands against this repository — not recalled from memory — which is the standard every code example on this page is held to.
Where this sits
twin sits directly on Foundation: every table it reads
comes back through the report contract and workspace layout that layer
defines, and every optional adapter it might need — lightsim2grid,
osmnx — goes through require_capabilities before use. What builds on
twin is Assets: the buildings, EVs and DER that the network
model's buildings and building_grid_connectivity tables anchor to bus and
transformer identities.