Concepts
This document explains the core architectural concepts of the CELINE Digital Twin runtime. Read this first to understand the mental model before diving into implementation details.
Status, verified against the code on 2026-08-15. Parts of this document describe an artifact/module generation of the runtime that the domain-based runtime replaced, and they were not updated with it. Verified against
src/celine/dt/main.py:
- No
/appsroute is mounted. The application mounts the discovery router and one router per domain, and nothing else. TheDTAppandDTComponentcontracts still exist undersrc/celine/dt/contracts/, but no API exposes them.- There is no module registry. No
DTRegistry, noregister_app, and no loader reads a config/modules.yaml.- Values and subscriptions are not YAML-configured. See Configuration Hierarchy below for what is actually loaded.
The current organising unit is the domain — see
domains.md. What should happen to the superseded sections is an open question for the maintainers rather than a mechanical fix, so they are marked rather than deleted.
The Three Artifact Types
The Digital Twin runtime is built around three artifact types, each serving a distinct purpose:
| Artifact | API | Side Effects | Description |
|---|---|---|---|
| Apps | /apps/{key}/run |
Allowed | Orchestrate operations, one-shot execution |
| Components | Internal only | None | Pure computation, stateless, composable |
| Simulations | /simulations/{key}/... |
None | Two-phase what-if exploration |
Apps
An App is a self-contained, externally-callable operation.
Key characteristics:
- Exposed via REST API (/apps/{key}/run)
- May have side effects (publish events, update state)
- Receives all dependencies via RunContext
- Contains orchestration logic, not computation
When to use: Real-time decisions, integrations, one-shot calculations.
Components
A Component is a pure, reusable computation unit.
Key characteristics: - Not directly exposed via API - Pure: same input → same output, no side effects - Stateless: no memory between calls - Composable: freely combined by apps and simulations
When to use: Energy calculations, profile generation, economic models.
Simulations
A Simulation enables what-if exploration with varying parameters.
Key characteristics:
- Exposed via REST API (/simulations/{key}/...)
- Two-phase execution: scenario (expensive) + runs (fast)
- Scenario caching for efficient parameter sweeps
- Built-in support for sensitivity analysis
When to use: Planning, optimization, scenario comparison.
The Two-Phase Simulation Model
Simulations separate expensive setup from fast exploration:
| Phase | Function | Duration | Description |
|---|---|---|---|
| Phase 1 — Build Scenario | build_scenario() |
seconds to minutes | Fetch historical data, compute baseline, store cached artifacts |
| Phase 2 — Run Simulations | simulate() |
milliseconds | Load cached scenario, apply parameters, compute results |
The scenario is built once and reused many times across parameter variations. Example scenario config: community_id, reference_period, resolution. Example simulation parameters: pv_kwp: 100, battery_kwh: 50, discount_rate: 0.05. Example results: self_consumption: 0.8, npv: €12,000, payback_years: 7.2.
This enables: - Parameter sweeps: Test 100 configurations against one scenario - Sensitivity analysis: See how results change with each parameter - Scenario comparison: Compare different communities or time periods
Registry and Registration
The DTRegistry is the central catalog of all artifacts:
from celine.dt.core.registry import DTRegistry
registry = DTRegistry()
# Apps
registry.register_app(MyApp())
# Components
registry.register_component(MyComponent())
# or: registry.components.register(MyComponent())
# Simulations
registry.register_simulation(MySimulation())
# or: registry.simulations.register(MySimulation())
The registry provides: - Lookup by key - Schema introspection - Module tracking - Ontology management
Superseded. There is no
DTRegistryin the current runtime. Domains are discovered fromconfig/domains.yamlby import path, and each one is asked for its own value specs, simulations, subscriptions and ontology specs.domains.mdis the current account.
RunContext: The Execution Environment
RunContext carries execution metadata and shared services. Apps, components, and simulations never access infrastructure directly—everything comes through context.
class MyApp:
async def run(self, config: MyConfig, context: RunContext) -> MyResult:
# Data access
data = await context.values.fetch("weather_forecast", {"location": "folgaria"})
# State management
state = await context.state.get("my-app")
# Event publishing
await context.publish_event(my_event)
# Request metadata
print(context.request_id)
print(context.now)
Available in context:
- values - Value fetchers for data access
- state - State store for persistence
- broker - Event broker for publishing
- token_provider - Authentication tokens
- request_id - Unique request identifier
- now - Current UTC timestamp
Modules: Packaging and Deployment
A Module is a deployable unit that groups related artifacts:
# my_module/module.py
from celine.dt.core.registry import DTRegistry
class MyModule:
name = "my-module"
version = "1.0.0"
def register(self, registry: DTRegistry) -> None:
registry.register_app(MyApp())
registry.register_component(MyComponent())
registry.register_simulation(MySimulation())
module = MyModule()
Superseded. The module registry above does not exist in the current runtime: there is no
DTRegistry, and no loader reads a config/modules.yaml. Packaging a vertical is done by writing aDTDomainand declaring it inconfig/domains.yaml— seedomains.md.
Clients: Data Backend Abstraction
Clients provide data access to external systems. They are configured via YAML and support dependency injection.
# config/clients.yaml
clients:
dataset_api:
class: celine.dt.core.datasets.dataset_api:DatasetSqlApiClient
inject:
- token_provider # Injected from app state
config:
base_url: "${DATASET_API_URL}"
timeout: 30.0
Clients implement a query interface:
class DatasetClient(ABC):
async def query(self, *, sql: str, limit: int, offset: int) -> list[dict]
def stream(self, *, sql: str, page_size: int) -> AsyncIterator[list[dict]]
Values: Declarative Data Fetching
Values are declarative data fetchers configured via YAML:
# config/values.yaml
values:
weather_forecast:
client: dataset_api
query: |
SELECT * FROM weather_forecasts
WHERE location = :location
AND date >= :start_date
limit: 100
payload:
type: object
required: [location]
properties:
location: { type: string }
start_date: { type: string, default: "2024-01-01" }
Superseded. Values are not declared in YAML. A domain returns
ValueFetcherSpecobjects fromget_value_specs(), and the runtime namespaces each id as{domain.name}.{id}and mounts it under that domain's prefix — so the path is/{route_prefix}/{entity_id}/values/{fetcher_id}, not a global/values/.... The query template rules are invalues.md, and the two-phase rendering trap is recorded in the companion's knowledge.
Brokers and Subscriptions: Event System
Brokers publish events to external systems (MQTT, etc.):
# config/brokers.yaml
brokers:
mqtt_local:
class: celine.dt.core.broker.mqtt:MqttBroker
config:
host: "${MQTT_HOST:-localhost}"
port: 1883
topic_prefix: "celine/dt/"
Subscriptions receive events:
# config/subscriptions.yaml
subscriptions:
- id: log-events
topics: ["dt/ev-charging/#"]
handler: "my.module:handle_event"
enabled: true
Configuration Hierarchy
The runtime loads exactly three YAML families, and each is a list of glob patterns overridable from the environment, not a fixed filename:
| Setting | Default pattern | Declares |
|---|---|---|
domains_config_paths |
config/domains.yaml |
domains: name, import path, enabled, overrides |
clients_config_paths |
config/clients.yaml |
data backends |
brokers_config_paths |
config/brokers.yaml |
event publishers |
Three properties follow, and each one bites:
- Opportunistic. A pattern matching nothing logs at debug level and returns empty. Startup continues. A mistyped pattern is therefore indistinguishable from a feature nobody configured.
- Merged. Every file matching any pattern is loaded and sorted for determinism, then
combined. For domains the merge key is the domain
name, so a later file replaces an earlier declaration of the same name rather than adding a second one. - Environment-substituted on load:
${VAR}is required,${VAR:-default}is not.
Domains are declared in code as DTDomain subclasses; the YAML selects and tunes them.
Values and subscriptions are not YAML-configured at all. Value fetchers come from a
domain's get_value_specs(); subscriptions come from get_subscriptions() and from
@on_event handlers collected by scan_handlers. Files named config/values.yaml,
config/subscriptions.yaml and config/modules.yaml appear elsewhere in this document set
and do not exist and are read by nothing.
Environment variable substitution:
| Syntax | Behavior |
|---|---|
${VAR} |
Required - fails if not set |
${VAR:-default} |
Optional - uses default if not set |
Design Principles
1. Transport Agnostic
Apps and simulations work identically whether called via: - REST API - Unit tests - Batch jobs - Event handlers
2. Infrastructure Injected
Domain logic never imports FastAPI, SQLAlchemy, or external libraries directly. Everything comes through RunContext.
3. Explicit Over Implicit
- All dependencies are visible in function signatures
- Configuration is declarative (YAML)
- Schemas are exposed automatically
4. Composition Over Inheritance
- Components compose freely
- Apps orchestrate components
- Simulations use components and apps
Next Steps
- Developer Guide - Build your first app, component, or simulation
- Simulations - Deep dive into the simulation engine
- Values API - Configure data fetchers