dyon 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dyon/__init__.py +16 -0
- dyon/_compat.py +66 -0
- dyon/autonomous/__init__.py +19 -0
- dyon/autonomous/base.py +29 -0
- dyon/autonomous/deployer.py +91 -0
- dyon/autonomous/gym_env.py +124 -0
- dyon/autonomous/ooda.py +253 -0
- dyon/autonomous/overseer.py +310 -0
- dyon/autonomous/planner.py +80 -0
- dyon/autonomous/trainer.py +67 -0
- dyon/cli/__init__.py +1 -0
- dyon/cli/main.py +285 -0
- dyon/collection/__init__.py +15 -0
- dyon/collection/aggregate.py +132 -0
- dyon/collection/base.py +87 -0
- dyon/collection/collection_twin.py +106 -0
- dyon/collection/composite.py +194 -0
- dyon/collection/network_twin.py +209 -0
- dyon/collection/orchestrator.py +56 -0
- dyon/connector/__init__.py +12 -0
- dyon/connector/api_connector.py +62 -0
- dyon/connector/base.py +71 -0
- dyon/connector/ditto_connector.py +109 -0
- dyon/connector/mqtt_connector.py +107 -0
- dyon/core/__init__.py +17 -0
- dyon/core/base.py +132 -0
- dyon/core/config.py +157 -0
- dyon/core/events.py +85 -0
- dyon/core/lifecycle.py +85 -0
- dyon/core/registry.py +55 -0
- dyon/core/types.py +57 -0
- dyon/data/__init__.py +13 -0
- dyon/data/management/__init__.py +4 -0
- dyon/data/management/health.py +47 -0
- dyon/data/management/pipeline.py +107 -0
- dyon/data/storage/__init__.py +16 -0
- dyon/data/storage/base.py +119 -0
- dyon/data/storage/influx.py +227 -0
- dyon/data/storage/minio_store.py +84 -0
- dyon/data/storage/mongo.py +117 -0
- dyon/data/storage/postgres.py +51 -0
- dyon/data/storage/provenance.py +124 -0
- dyon/data/storage/redis_store.py +88 -0
- dyon/data/text_ingestor.py +157 -0
- dyon/data/writer.py +103 -0
- dyon/infra/__init__.py +4 -0
- dyon/infra/docker.py +349 -0
- dyon/infra/health_check.py +100 -0
- dyon/intelligent/__init__.py +15 -0
- dyon/intelligent/agent.py +152 -0
- dyon/intelligent/base.py +42 -0
- dyon/intelligent/knowledge_graph.py +220 -0
- dyon/intelligent/mas.py +228 -0
- dyon/intelligent/tools.py +100 -0
- dyon/learning/__init__.py +64 -0
- dyon/learning/_util.py +62 -0
- dyon/learning/demonstrations.py +205 -0
- dyon/learning/features.py +182 -0
- dyon/learning/imitation_trainers.py +168 -0
- dyon/learning/irl_trainers.py +174 -0
- dyon/learning/maxent.py +180 -0
- dyon/learning/pipeline.py +296 -0
- dyon/learning/reward.py +126 -0
- dyon/ml/__init__.py +3 -0
- dyon/ml/corpus.py +102 -0
- dyon/network/__init__.py +4 -0
- dyon/network/ingestor.py +102 -0
- dyon/network/transport.py +107 -0
- dyon/notifications/__init__.py +3 -0
- dyon/notifications/notifier.py +104 -0
- dyon/physical/__init__.py +4 -0
- dyon/physical/adapters/__init__.py +1 -0
- dyon/physical/base.py +34 -0
- dyon/physical/simulator.py +141 -0
- dyon/reactive/__init__.py +16 -0
- dyon/reactive/actions.py +50 -0
- dyon/reactive/base.py +31 -0
- dyon/reactive/fsm_engine.py +205 -0
- dyon/reactive/pid.py +95 -0
- dyon/reactive/rule_engine.py +211 -0
- dyon/reactive/rule_repository.py +185 -0
- dyon/services/__init__.py +4 -0
- dyon/services/api/__init__.py +3 -0
- dyon/services/api/app.py +56 -0
- dyon/services/api/routes.py +94 -0
- dyon/services/api/streaming.py +64 -0
- dyon/services/base.py +66 -0
- dyon/services/composite.py +35 -0
- dyon/services/ditto/__init__.py +4 -0
- dyon/services/ditto/client.py +183 -0
- dyon/services/ditto/sync.py +87 -0
- dyon/session/__init__.py +3 -0
- dyon/session/context.py +175 -0
- dyon/simulation/__init__.py +16 -0
- dyon/simulation/base.py +22 -0
- dyon/simulation/discrete_event.py +61 -0
- dyon/simulation/forecaster.py +55 -0
- dyon/simulation/ode_model.py +77 -0
- dyon/simulation/runner.py +110 -0
- dyon/simulation/surrogate.py +79 -0
- dyon-0.7.0.dist-info/METADATA +419 -0
- dyon-0.7.0.dist-info/RECORD +105 -0
- dyon-0.7.0.dist-info/WHEEL +5 -0
- dyon-0.7.0.dist-info/entry_points.txt +3 -0
- dyon-0.7.0.dist-info/top_level.txt +1 -0
dyon/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Dyon: Domain-agnostic Python framework for digital twin architectures."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.6.0"
|
|
4
|
+
|
|
5
|
+
from dyon.core.base import AbstractDigitalTwin, LayerBase
|
|
6
|
+
from dyon.core.config import TwinConfig, SensorFieldSpec
|
|
7
|
+
from dyon.core.events import EventBus, DomainEvent
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AbstractDigitalTwin",
|
|
11
|
+
"LayerBase",
|
|
12
|
+
"TwinConfig",
|
|
13
|
+
"SensorFieldSpec",
|
|
14
|
+
"EventBus",
|
|
15
|
+
"DomainEvent",
|
|
16
|
+
]
|
dyon/_compat.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Compatibility support for the legacy ``dt_forge`` import name.
|
|
2
|
+
|
|
3
|
+
``dt_forge`` was renamed to ``dyon``. :func:`install_alias` registers a meta-path
|
|
4
|
+
hook so that every ``dt_forge`` / ``dt_forge.*`` import is served by the matching
|
|
5
|
+
``dyon`` module — the *same* module object, so identity, ``isinstance`` and
|
|
6
|
+
module-level singletons stay consistent across both names.
|
|
7
|
+
|
|
8
|
+
The logic lives here (shipped with ``dyon``) so the ``dt_forge`` shim package is a
|
|
9
|
+
trivial two-line forwarder with nothing to drift out of sync.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import sys
|
|
16
|
+
import warnings
|
|
17
|
+
from importlib.abc import Loader, MetaPathFinder
|
|
18
|
+
from importlib.machinery import ModuleSpec
|
|
19
|
+
|
|
20
|
+
_OLD = "dt_forge"
|
|
21
|
+
_NEW = "dyon"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _AliasLoader(Loader):
|
|
25
|
+
"""Loads an old-name module by handing back the real ``dyon`` module object."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, target: str) -> None:
|
|
28
|
+
self._target = target
|
|
29
|
+
|
|
30
|
+
def create_module(self, spec: ModuleSpec):
|
|
31
|
+
module = importlib.import_module(self._target)
|
|
32
|
+
sys.modules[spec.name] = module
|
|
33
|
+
return module
|
|
34
|
+
|
|
35
|
+
def exec_module(self, module) -> None: # already initialised as ``dyon.*``
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _AliasFinder(MetaPathFinder):
|
|
40
|
+
"""Redirects ``dt_forge`` / ``dt_forge.<sub>`` to ``dyon`` / ``dyon.<sub>``."""
|
|
41
|
+
|
|
42
|
+
def find_spec(self, fullname, path=None, target=None):
|
|
43
|
+
if fullname == _OLD or fullname.startswith(_OLD + "."):
|
|
44
|
+
return ModuleSpec(fullname, _AliasLoader(_NEW + fullname[len(_OLD):]))
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def install_alias(*, warn: bool = True) -> None:
|
|
49
|
+
"""Make ``dt_forge`` resolve to ``dyon``. Idempotent; safe to call repeatedly."""
|
|
50
|
+
if not any(isinstance(f, _AliasFinder) for f in sys.meta_path):
|
|
51
|
+
# Insert ahead of the default path finder so submodules alias rather than
|
|
52
|
+
# re-import under a second name.
|
|
53
|
+
sys.meta_path.insert(0, _AliasFinder())
|
|
54
|
+
|
|
55
|
+
if warn:
|
|
56
|
+
warnings.warn(
|
|
57
|
+
"'dt_forge' has been renamed to 'dyon'. Update your imports to 'dyon' "
|
|
58
|
+
"(e.g. 'import dyon', 'from dyon.core.config import TwinConfig'). The "
|
|
59
|
+
"'dt_forge' compatibility alias will be removed in a future major release.",
|
|
60
|
+
DeprecationWarning,
|
|
61
|
+
stacklevel=3,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Serve ``import dt_forge`` itself from the real package, so attribute access
|
|
65
|
+
# (``dt_forge.AbstractDigitalTwin``) resolves identically to ``dyon``.
|
|
66
|
+
sys.modules[_OLD] = importlib.import_module(_NEW)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dyon.autonomous.base import AbstractAutonomousController
|
|
2
|
+
from dyon.autonomous.ooda import OODALoop
|
|
3
|
+
from dyon.autonomous.overseer import AutonomousOverseer, OverseerDecision
|
|
4
|
+
from dyon.autonomous.planner import GoalPlanner, Goal
|
|
5
|
+
from dyon.autonomous.gym_env import GenericTwinEnv
|
|
6
|
+
from dyon.autonomous.trainer import PolicyTrainer
|
|
7
|
+
from dyon.autonomous.deployer import PolicyDeployer
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AbstractAutonomousController",
|
|
11
|
+
"OODALoop",
|
|
12
|
+
"AutonomousOverseer",
|
|
13
|
+
"OverseerDecision",
|
|
14
|
+
"GoalPlanner",
|
|
15
|
+
"Goal",
|
|
16
|
+
"GenericTwinEnv",
|
|
17
|
+
"PolicyTrainer",
|
|
18
|
+
"PolicyDeployer",
|
|
19
|
+
]
|
dyon/autonomous/base.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Abstract base for autonomous controllers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AbstractAutonomousController(ABC):
|
|
9
|
+
"""Base class for any autonomous control strategy."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def observe(self) -> dict:
|
|
13
|
+
"""Collect a comprehensive situation snapshot."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
async def orient(self, observation: dict) -> dict:
|
|
18
|
+
"""Contextualise the situation against goals."""
|
|
19
|
+
...
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
async def decide(self, observation: dict, assessment: dict) -> dict:
|
|
23
|
+
"""Select an action plan."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
async def act(self, plan: dict) -> None:
|
|
28
|
+
"""Execute the decided plan."""
|
|
29
|
+
...
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""PolicyDeployer: live RL inference using a trained SB3 policy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from dyon.data.storage.base import TimeSeriesStore
|
|
12
|
+
from dyon.network.transport import MQTTTransport
|
|
13
|
+
from dyon.core.config import TwinConfig
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PolicyDeployer:
|
|
19
|
+
"""
|
|
20
|
+
Loads a trained SB3 policy and applies it in a live twin loop.
|
|
21
|
+
|
|
22
|
+
On each step_once() call:
|
|
23
|
+
1. Reads current observations from InfluxDB
|
|
24
|
+
2. Runs the policy to get an action
|
|
25
|
+
3. Publishes the control command via MQTT
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
policy_path: str | None,
|
|
31
|
+
config: "TwinConfig",
|
|
32
|
+
ts_store: "TimeSeriesStore",
|
|
33
|
+
mqtt_transport: "MQTTTransport",
|
|
34
|
+
obs_fields: list[str],
|
|
35
|
+
control_field: str,
|
|
36
|
+
ctrl_min: float,
|
|
37
|
+
ctrl_max: float,
|
|
38
|
+
algorithm: str = "SAC",
|
|
39
|
+
policy=None,
|
|
40
|
+
):
|
|
41
|
+
from stable_baselines3 import SAC, TD3, PPO, A2C
|
|
42
|
+
|
|
43
|
+
self._cfg = config
|
|
44
|
+
self._ts = ts_store
|
|
45
|
+
self._mqtt = mqtt_transport
|
|
46
|
+
self._obs_fields = obs_fields
|
|
47
|
+
self._control_field = control_field
|
|
48
|
+
self._ctrl_min = ctrl_min
|
|
49
|
+
self._ctrl_max = ctrl_max
|
|
50
|
+
|
|
51
|
+
if policy is not None:
|
|
52
|
+
# An already-loaded policy/algorithm (e.g. a BC or imitation policy,
|
|
53
|
+
# or anything exposing .predict(obs, deterministic=...)).
|
|
54
|
+
self._policy = policy
|
|
55
|
+
log.info("PolicyDeployer using preloaded policy (%s)", algorithm)
|
|
56
|
+
else:
|
|
57
|
+
# Imitation/BC artifacts are saved in a PPO container, so "BC" loads
|
|
58
|
+
# through PPO.
|
|
59
|
+
_algo_map = {"SAC": SAC, "TD3": TD3, "PPO": PPO, "A2C": A2C, "BC": PPO}
|
|
60
|
+
AlgoClass = _algo_map.get(algorithm.upper(), SAC)
|
|
61
|
+
self._policy = AlgoClass.load(policy_path)
|
|
62
|
+
log.info("PolicyDeployer loaded '%s' (%s)", policy_path, algorithm)
|
|
63
|
+
|
|
64
|
+
async def _get_observation(self) -> np.ndarray:
|
|
65
|
+
# A field with no data falls back to its configured nominal value rather
|
|
66
|
+
# than a hard 0.0 — a 0.0 can sit outside the policy's observation bounds
|
|
67
|
+
# and misrepresents "no reading" as a genuine zero.
|
|
68
|
+
latest = await self._ts.aget_latest_fields(self._obs_fields)
|
|
69
|
+
specs = self._cfg.field_specs
|
|
70
|
+
obs = []
|
|
71
|
+
for f in self._obs_fields:
|
|
72
|
+
v = latest.get(f)
|
|
73
|
+
if v is None:
|
|
74
|
+
spec = specs.get(f)
|
|
75
|
+
v = spec.nominal if spec and spec.nominal is not None else 0.0
|
|
76
|
+
obs.append(v)
|
|
77
|
+
return np.array(obs, dtype=np.float32)
|
|
78
|
+
|
|
79
|
+
def _scale_action(self, action: np.ndarray) -> float:
|
|
80
|
+
a = float(np.clip(action[0], -1.0, 1.0))
|
|
81
|
+
return self._ctrl_min + (a + 1.0) / 2.0 * (self._ctrl_max - self._ctrl_min)
|
|
82
|
+
|
|
83
|
+
async def step_once(self) -> float:
|
|
84
|
+
obs = await self._get_observation()
|
|
85
|
+
action, _ = self._policy.predict(obs, deterministic=True)
|
|
86
|
+
ctrl = self._scale_action(action)
|
|
87
|
+
self._mqtt.publish(
|
|
88
|
+
self._cfg.topic_control,
|
|
89
|
+
{self._control_field: round(ctrl, 4)},
|
|
90
|
+
)
|
|
91
|
+
return ctrl
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""GenericTwinEnv: Gymnasium environment wrapping any TwinModel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Callable, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import gymnasium as gym
|
|
9
|
+
import numpy as np
|
|
10
|
+
from gymnasium import spaces
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from dyon.simulation.base import TwinModel
|
|
14
|
+
|
|
15
|
+
log = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GenericTwinEnv(gym.Env):
|
|
19
|
+
"""
|
|
20
|
+
Gymnasium environment that wraps any TwinModel.
|
|
21
|
+
|
|
22
|
+
Configuration:
|
|
23
|
+
model — TwinModel instance to use as the environment
|
|
24
|
+
control_field — name of the variable the agent adjusts
|
|
25
|
+
process_variable— name of the variable being regulated
|
|
26
|
+
target — desired value for process_variable
|
|
27
|
+
ctrl_min/max — valid range for control output
|
|
28
|
+
obs_fields — field names forming the observation
|
|
29
|
+
obs_low/high — bounds for observation space
|
|
30
|
+
reward_fn — optional custom reward function(obs, target) → float
|
|
31
|
+
max_steps — episode length
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
metadata = {"render_modes": []}
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
model: "TwinModel",
|
|
40
|
+
control_field: str,
|
|
41
|
+
process_variable: str,
|
|
42
|
+
target: float,
|
|
43
|
+
ctrl_min: float,
|
|
44
|
+
ctrl_max: float,
|
|
45
|
+
obs_fields: list[str],
|
|
46
|
+
obs_low: np.ndarray,
|
|
47
|
+
obs_high: np.ndarray,
|
|
48
|
+
reward_fn: Callable | None = None,
|
|
49
|
+
max_steps: int = 500,
|
|
50
|
+
):
|
|
51
|
+
super().__init__()
|
|
52
|
+
self.model = model
|
|
53
|
+
self.control_field = control_field
|
|
54
|
+
self.process_variable = process_variable
|
|
55
|
+
self.target = target
|
|
56
|
+
self.ctrl_min = ctrl_min
|
|
57
|
+
self.ctrl_max = ctrl_max
|
|
58
|
+
self.obs_fields = obs_fields
|
|
59
|
+
self.reward_fn = reward_fn or self._default_reward
|
|
60
|
+
self.max_steps = max_steps
|
|
61
|
+
self._step_count = 0
|
|
62
|
+
self._last_obs: dict[str, float] = {}
|
|
63
|
+
|
|
64
|
+
self.observation_space = spaces.Box(
|
|
65
|
+
obs_low.astype(np.float32),
|
|
66
|
+
obs_high.astype(np.float32),
|
|
67
|
+
dtype=np.float32,
|
|
68
|
+
)
|
|
69
|
+
self.action_space = spaces.Box(
|
|
70
|
+
np.array([-1.0], dtype=np.float32),
|
|
71
|
+
np.array([1.0], dtype=np.float32),
|
|
72
|
+
shape=(1,),
|
|
73
|
+
dtype=np.float32,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _default_reward(self, obs: dict, target: float) -> float:
|
|
77
|
+
# Prefer the simulator-prefixed value but fall back to the bare field.
|
|
78
|
+
# ``or`` chains would mis-fire when the value is a legitimate 0.0, so
|
|
79
|
+
# check membership / None explicitly.
|
|
80
|
+
pv = obs.get(f"sim_{self.process_variable}")
|
|
81
|
+
if pv is None:
|
|
82
|
+
pv = obs.get(self.process_variable)
|
|
83
|
+
if pv is None:
|
|
84
|
+
pv = 0.0
|
|
85
|
+
return -abs(pv - target)
|
|
86
|
+
|
|
87
|
+
def _scale_action(self, action: np.ndarray) -> float:
|
|
88
|
+
"""Map [-1, 1] → [ctrl_min, ctrl_max]."""
|
|
89
|
+
a = float(np.clip(action[0], -1.0, 1.0))
|
|
90
|
+
return self.ctrl_min + (a + 1.0) / 2.0 * (self.ctrl_max - self.ctrl_min)
|
|
91
|
+
|
|
92
|
+
def _get_obs(self) -> np.ndarray:
|
|
93
|
+
return np.array(
|
|
94
|
+
[self._last_obs.get(f, 0.0) for f in self.obs_fields],
|
|
95
|
+
dtype=np.float32,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def reset(self, *, seed=None, options=None):
|
|
99
|
+
super().reset(seed=seed)
|
|
100
|
+
self.model.reset()
|
|
101
|
+
self._step_count = 0
|
|
102
|
+
# Prime the first observation from a real model step at the neutral
|
|
103
|
+
# action (0 → mid-range control), instead of returning an all-zeros
|
|
104
|
+
# vector: zeros ignore the model's actual initial state and can fall
|
|
105
|
+
# outside the observation Box bounds when obs_low > 0.
|
|
106
|
+
neutral_ctrl = self._scale_action(np.array([0.0], dtype=np.float32))
|
|
107
|
+
self._last_obs = self.model.step(
|
|
108
|
+
dt=1.0, inputs={self.control_field: neutral_ctrl}
|
|
109
|
+
)
|
|
110
|
+
obs = self._get_obs()
|
|
111
|
+
return obs, {}
|
|
112
|
+
|
|
113
|
+
def step(self, action: np.ndarray):
|
|
114
|
+
ctrl = self._scale_action(action)
|
|
115
|
+
outputs = self.model.step(dt=1.0, inputs={self.control_field: ctrl})
|
|
116
|
+
self._last_obs = outputs
|
|
117
|
+
obs = self._get_obs()
|
|
118
|
+
reward = self.reward_fn(outputs, self.target)
|
|
119
|
+
self._step_count += 1
|
|
120
|
+
terminated = self._step_count >= self.max_steps
|
|
121
|
+
return obs, reward, terminated, False, {}
|
|
122
|
+
|
|
123
|
+
def render(self): ...
|
|
124
|
+
def close(self): ...
|
dyon/autonomous/ooda.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""OODALoop: Observe-Orient-Decide-Act autonomous control layer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from dyon.autonomous.base import AbstractAutonomousController
|
|
11
|
+
from dyon.core.base import LayerBase
|
|
12
|
+
from dyon.core.events import DomainEvent
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from dyon.autonomous.deployer import PolicyDeployer
|
|
16
|
+
from dyon.autonomous.overseer import AutonomousOverseer
|
|
17
|
+
from dyon.autonomous.planner import GoalPlanner
|
|
18
|
+
from dyon.core.config import TwinConfig
|
|
19
|
+
from dyon.core.events import EventBus
|
|
20
|
+
from dyon.data.storage.base import CacheStore, DocumentStore, TimeSeriesStore
|
|
21
|
+
from dyon.intelligent.mas import MultiAgentSystem
|
|
22
|
+
from dyon.notifications.notifier import HumanNotifier
|
|
23
|
+
from dyon.reactive.rule_engine import ThresholdRuleEngine
|
|
24
|
+
from dyon.services.ditto.client import DittoClient
|
|
25
|
+
from dyon.simulation.base import TwinModel
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Maps the assessment/overseer risk vocabulary to the three log-event severity
|
|
31
|
+
# levels the framework uses (DocumentStore.log_event accepts info/warning/critical).
|
|
32
|
+
_RISK_TO_SEVERITY = {
|
|
33
|
+
"low": "info",
|
|
34
|
+
"medium": "info",
|
|
35
|
+
"high": "warning",
|
|
36
|
+
"critical": "critical",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class OODALoop(LayerBase, AbstractAutonomousController):
|
|
41
|
+
"""
|
|
42
|
+
The OODA loop provides full situational awareness and autonomous control.
|
|
43
|
+
|
|
44
|
+
Observe — gather state from all lower layers
|
|
45
|
+
Orient — contextualise using models, history, goals
|
|
46
|
+
Decide — select action plan (rule-based, RL policy, goal planner)
|
|
47
|
+
Act — execute actions on lower layers or via connectors
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
layer_name = "autonomous"
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
config: "TwinConfig",
|
|
55
|
+
event_bus: "EventBus",
|
|
56
|
+
*,
|
|
57
|
+
ts_store: "TimeSeriesStore",
|
|
58
|
+
cache: "CacheStore",
|
|
59
|
+
doc_store: "DocumentStore",
|
|
60
|
+
ditto_client: "DittoClient",
|
|
61
|
+
models: dict[str, "TwinModel"],
|
|
62
|
+
reactive: "ThresholdRuleEngine",
|
|
63
|
+
mas: "MultiAgentSystem",
|
|
64
|
+
connectors: list,
|
|
65
|
+
planner: "GoalPlanner",
|
|
66
|
+
policy: "PolicyDeployer | None" = None,
|
|
67
|
+
loop_interval: int = 5,
|
|
68
|
+
notifier: "HumanNotifier | None" = None,
|
|
69
|
+
overseer: "AutonomousOverseer | None" = None,
|
|
70
|
+
):
|
|
71
|
+
super().__init__(config, event_bus)
|
|
72
|
+
self.ts = ts_store
|
|
73
|
+
self.cache = cache
|
|
74
|
+
self.doc = doc_store
|
|
75
|
+
self.ditto = ditto_client
|
|
76
|
+
self.models = models
|
|
77
|
+
self.reactive = reactive
|
|
78
|
+
self.mas = mas
|
|
79
|
+
self.connectors = connectors
|
|
80
|
+
self.planner = planner
|
|
81
|
+
self.policy = policy
|
|
82
|
+
self.loop_interval = loop_interval
|
|
83
|
+
self.notifier = notifier
|
|
84
|
+
# Optional LLM overseer — when set, its strategic decision is merged
|
|
85
|
+
# into the assessment dict the rule-based planner produces.
|
|
86
|
+
self.overseer = overseer
|
|
87
|
+
|
|
88
|
+
async def observe(self) -> dict:
|
|
89
|
+
base = {
|
|
90
|
+
"state": await self.cache.aget_state(),
|
|
91
|
+
"health": await self.cache.aget_latest_cached("health_score"),
|
|
92
|
+
"telemetry": await self.ts.aget_latest_fields(self.config.field_names),
|
|
93
|
+
"recent_events": await self.doc.aget_recent_events(5),
|
|
94
|
+
}
|
|
95
|
+
if self.mas:
|
|
96
|
+
base["mas_findings"] = {
|
|
97
|
+
a.agent_name: self.mas.get_agent_detail(a.agent_name)
|
|
98
|
+
for a in self.mas.agents
|
|
99
|
+
}
|
|
100
|
+
return base
|
|
101
|
+
|
|
102
|
+
async def direct_agent(self, agent_name: str, question: str) -> str:
|
|
103
|
+
"""Send a targeted query to a named MAS agent from the autonomous layer."""
|
|
104
|
+
if self.mas is None:
|
|
105
|
+
return "MAS layer not available."
|
|
106
|
+
return await self.mas.ask_agent(agent_name, question)
|
|
107
|
+
|
|
108
|
+
async def orient(self, observation: dict) -> dict:
|
|
109
|
+
assessment = await self.planner.assess(observation)
|
|
110
|
+
if self.overseer is not None:
|
|
111
|
+
try:
|
|
112
|
+
decision = await self.overseer.run(observation)
|
|
113
|
+
# The overseer's strategic decision joins the rule-based
|
|
114
|
+
# assessment under explicit keys so safety constraints in
|
|
115
|
+
# decide() can still take precedence over it.
|
|
116
|
+
assessment["overseer_action"] = decision.action
|
|
117
|
+
assessment["overseer_reasoning"] = decision.reasoning
|
|
118
|
+
assessment["overseer_risk_level"] = decision.risk_level
|
|
119
|
+
assessment["overseer_goals_addressed"] = decision.goals_addressed
|
|
120
|
+
assessment["overseer_agent_queries"] = decision.agent_queries
|
|
121
|
+
# Audit trail for every overseer decision.
|
|
122
|
+
try:
|
|
123
|
+
await self.doc.alog_event(
|
|
124
|
+
"ooda_overseer_decision",
|
|
125
|
+
{
|
|
126
|
+
"action": decision.action,
|
|
127
|
+
"reasoning": decision.reasoning,
|
|
128
|
+
"risk_level": decision.risk_level,
|
|
129
|
+
"goals_addressed": decision.goals_addressed,
|
|
130
|
+
"agent_queries": decision.agent_queries,
|
|
131
|
+
},
|
|
132
|
+
severity="info",
|
|
133
|
+
)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
self.log.debug("Overseer audit log failed: %s", e)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
self.log.error("Overseer.run failed: %s", e)
|
|
138
|
+
return assessment
|
|
139
|
+
|
|
140
|
+
async def decide(self, observation: dict, assessment: dict) -> dict:
|
|
141
|
+
# Layer 1 — hard safety constraints from the rule-based planner.
|
|
142
|
+
# These override the overseer.
|
|
143
|
+
if assessment.get("requires_human_intervention"):
|
|
144
|
+
return {
|
|
145
|
+
"action": "request_human",
|
|
146
|
+
"reason": assessment.get("reason", "intervention required"),
|
|
147
|
+
}
|
|
148
|
+
if assessment.get("requires_shutdown"):
|
|
149
|
+
return {
|
|
150
|
+
"action": "shutdown",
|
|
151
|
+
"reason": assessment.get("reason", "autonomous shutdown"),
|
|
152
|
+
}
|
|
153
|
+
if assessment.get("needs_external_data"):
|
|
154
|
+
return {
|
|
155
|
+
"action": "query_peer",
|
|
156
|
+
"target_twin": assessment.get("target_twin", ""),
|
|
157
|
+
"query": assessment.get("query", {}),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# Layer 2 — strategic decision from the overseer (if configured).
|
|
161
|
+
# The overseer chooses among the actions declared at construction time.
|
|
162
|
+
overseer_action = assessment.get("overseer_action")
|
|
163
|
+
if overseer_action and overseer_action != "no_action":
|
|
164
|
+
return {
|
|
165
|
+
"action": overseer_action,
|
|
166
|
+
"reason": assessment.get("overseer_reasoning", ""),
|
|
167
|
+
"risk_level": assessment.get("overseer_risk_level", "low"),
|
|
168
|
+
"source": "overseer",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# Layer 3 — RL tactical optimisation when conditions are nominal.
|
|
172
|
+
if self.policy and assessment.get("risk_level") == "low":
|
|
173
|
+
return {"action": "rl_control"}
|
|
174
|
+
return {"action": "maintain_current"}
|
|
175
|
+
|
|
176
|
+
async def act(self, plan: dict) -> None:
|
|
177
|
+
action = plan["action"]
|
|
178
|
+
|
|
179
|
+
if action == "request_human":
|
|
180
|
+
await self.doc.alog_event(
|
|
181
|
+
"human_intervention_requested", plan, severity="critical"
|
|
182
|
+
)
|
|
183
|
+
await self.bus.publish(
|
|
184
|
+
DomainEvent(
|
|
185
|
+
event_type="autonomous.human_requested",
|
|
186
|
+
source_layer="autonomous",
|
|
187
|
+
source_asset=self.config.asset_id,
|
|
188
|
+
payload=plan,
|
|
189
|
+
severity="critical",
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
self.log.warning("Human intervention requested: %s", plan.get("reason"))
|
|
193
|
+
if self.notifier:
|
|
194
|
+
await self.notifier.send(plan.get("reason", "intervention required"), plan)
|
|
195
|
+
|
|
196
|
+
elif action == "shutdown":
|
|
197
|
+
self.reactive.shutdown_asset() # type: ignore[attr-defined]
|
|
198
|
+
await self.doc.alog_event("autonomous_shutdown", plan, severity="critical")
|
|
199
|
+
|
|
200
|
+
elif action == "query_peer":
|
|
201
|
+
for conn in self.connectors:
|
|
202
|
+
if conn.can_reach(plan.get("target_twin", "")):
|
|
203
|
+
result = await conn.query(plan["target_twin"], plan.get("query", {}))
|
|
204
|
+
await self.doc.alog_event("peer_query", {"result": result}, severity="info")
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
elif action == "rl_control":
|
|
208
|
+
if self.policy:
|
|
209
|
+
await self.policy.step_once()
|
|
210
|
+
|
|
211
|
+
elif plan.get("source") == "overseer":
|
|
212
|
+
# Overseer-defined action: publish an event so domain code can act
|
|
213
|
+
# on it. Subclasses that know the overseer's action vocabulary
|
|
214
|
+
# should override act() to drive their own actuators.
|
|
215
|
+
severity = _RISK_TO_SEVERITY.get(plan.get("risk_level", "low"), "info")
|
|
216
|
+
await self.bus.publish(
|
|
217
|
+
DomainEvent(
|
|
218
|
+
event_type=f"autonomous.{action}",
|
|
219
|
+
source_layer="autonomous",
|
|
220
|
+
source_asset=self.config.asset_id,
|
|
221
|
+
payload=plan,
|
|
222
|
+
severity=severity,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
await self.doc.alog_event(
|
|
226
|
+
f"autonomous_{action}",
|
|
227
|
+
plan,
|
|
228
|
+
severity=severity,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
async def start(self) -> None:
|
|
232
|
+
self._running = True
|
|
233
|
+
self.log.info("OODA autonomous loop started (interval=%ds)", self.loop_interval)
|
|
234
|
+
while self._running:
|
|
235
|
+
try:
|
|
236
|
+
obs = await self.observe()
|
|
237
|
+
assessment = await self.orient(obs)
|
|
238
|
+
plan = await self.decide(obs, assessment)
|
|
239
|
+
await self.act(plan)
|
|
240
|
+
try:
|
|
241
|
+
# Publish a compact summary for the dashboard and monitoring tools.
|
|
242
|
+
# Wrapped in try/except so a Redis outage cannot kill the control loop.
|
|
243
|
+
await self.cache.aset_latest("ooda_last_cycle", {
|
|
244
|
+
"action": plan.get("action", "unknown"),
|
|
245
|
+
"reason": plan.get("reason", ""),
|
|
246
|
+
"risk_level": assessment.get("risk_level", ""),
|
|
247
|
+
"ts_s": int(time.time()),
|
|
248
|
+
})
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
except Exception as e:
|
|
252
|
+
self.log.error("OODA loop error: %s", e)
|
|
253
|
+
await asyncio.sleep(self.loop_interval)
|