dt-forge 0.3.0__tar.gz
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.
- dt_forge-0.3.0/PKG-INFO +44 -0
- dt_forge-0.3.0/dt_forge/__init__.py +16 -0
- dt_forge-0.3.0/dt_forge/autonomous/__init__.py +19 -0
- dt_forge-0.3.0/dt_forge/autonomous/base.py +29 -0
- dt_forge-0.3.0/dt_forge/autonomous/deployer.py +74 -0
- dt_forge-0.3.0/dt_forge/autonomous/gym_env.py +109 -0
- dt_forge-0.3.0/dt_forge/autonomous/ooda.py +176 -0
- dt_forge-0.3.0/dt_forge/autonomous/overseer.py +255 -0
- dt_forge-0.3.0/dt_forge/autonomous/planner.py +73 -0
- dt_forge-0.3.0/dt_forge/autonomous/trainer.py +67 -0
- dt_forge-0.3.0/dt_forge/cli/__init__.py +1 -0
- dt_forge-0.3.0/dt_forge/cli/main.py +268 -0
- dt_forge-0.3.0/dt_forge/collection/__init__.py +15 -0
- dt_forge-0.3.0/dt_forge/collection/aggregate.py +132 -0
- dt_forge-0.3.0/dt_forge/collection/base.py +77 -0
- dt_forge-0.3.0/dt_forge/collection/collection_twin.py +107 -0
- dt_forge-0.3.0/dt_forge/collection/composite.py +144 -0
- dt_forge-0.3.0/dt_forge/collection/network_twin.py +189 -0
- dt_forge-0.3.0/dt_forge/collection/orchestrator.py +39 -0
- dt_forge-0.3.0/dt_forge/connector/__init__.py +12 -0
- dt_forge-0.3.0/dt_forge/connector/api_connector.py +62 -0
- dt_forge-0.3.0/dt_forge/connector/base.py +71 -0
- dt_forge-0.3.0/dt_forge/connector/ditto_connector.py +77 -0
- dt_forge-0.3.0/dt_forge/connector/mqtt_connector.py +83 -0
- dt_forge-0.3.0/dt_forge/core/__init__.py +18 -0
- dt_forge-0.3.0/dt_forge/core/base.py +95 -0
- dt_forge-0.3.0/dt_forge/core/config.py +153 -0
- dt_forge-0.3.0/dt_forge/core/events.py +74 -0
- dt_forge-0.3.0/dt_forge/core/lifecycle.py +65 -0
- dt_forge-0.3.0/dt_forge/core/registry.py +77 -0
- dt_forge-0.3.0/dt_forge/core/types.py +57 -0
- dt_forge-0.3.0/dt_forge/data/__init__.py +13 -0
- dt_forge-0.3.0/dt_forge/data/management/__init__.py +4 -0
- dt_forge-0.3.0/dt_forge/data/management/health.py +51 -0
- dt_forge-0.3.0/dt_forge/data/management/pipeline.py +96 -0
- dt_forge-0.3.0/dt_forge/data/storage/__init__.py +16 -0
- dt_forge-0.3.0/dt_forge/data/storage/base.py +84 -0
- dt_forge-0.3.0/dt_forge/data/storage/influx.py +136 -0
- dt_forge-0.3.0/dt_forge/data/storage/minio_store.py +84 -0
- dt_forge-0.3.0/dt_forge/data/storage/mongo.py +94 -0
- dt_forge-0.3.0/dt_forge/data/storage/postgres.py +47 -0
- dt_forge-0.3.0/dt_forge/data/storage/provenance.py +58 -0
- dt_forge-0.3.0/dt_forge/data/storage/redis_store.py +65 -0
- dt_forge-0.3.0/dt_forge/data/text_ingestor.py +140 -0
- dt_forge-0.3.0/dt_forge/data/writer.py +103 -0
- dt_forge-0.3.0/dt_forge/infra/__init__.py +4 -0
- dt_forge-0.3.0/dt_forge/infra/docker.py +350 -0
- dt_forge-0.3.0/dt_forge/infra/health_check.py +95 -0
- dt_forge-0.3.0/dt_forge/intelligent/__init__.py +15 -0
- dt_forge-0.3.0/dt_forge/intelligent/agent.py +150 -0
- dt_forge-0.3.0/dt_forge/intelligent/base.py +42 -0
- dt_forge-0.3.0/dt_forge/intelligent/knowledge_graph.py +184 -0
- dt_forge-0.3.0/dt_forge/intelligent/mas.py +225 -0
- dt_forge-0.3.0/dt_forge/intelligent/tools.py +100 -0
- dt_forge-0.3.0/dt_forge/ml/__init__.py +3 -0
- dt_forge-0.3.0/dt_forge/ml/corpus.py +85 -0
- dt_forge-0.3.0/dt_forge/network/__init__.py +4 -0
- dt_forge-0.3.0/dt_forge/network/ingestor.py +96 -0
- dt_forge-0.3.0/dt_forge/network/transport.py +98 -0
- dt_forge-0.3.0/dt_forge/notifications/__init__.py +3 -0
- dt_forge-0.3.0/dt_forge/notifications/notifier.py +103 -0
- dt_forge-0.3.0/dt_forge/physical/__init__.py +4 -0
- dt_forge-0.3.0/dt_forge/physical/adapters/__init__.py +1 -0
- dt_forge-0.3.0/dt_forge/physical/base.py +34 -0
- dt_forge-0.3.0/dt_forge/physical/simulator.py +107 -0
- dt_forge-0.3.0/dt_forge/reactive/__init__.py +16 -0
- dt_forge-0.3.0/dt_forge/reactive/actions.py +50 -0
- dt_forge-0.3.0/dt_forge/reactive/base.py +31 -0
- dt_forge-0.3.0/dt_forge/reactive/fsm_engine.py +204 -0
- dt_forge-0.3.0/dt_forge/reactive/pid.py +94 -0
- dt_forge-0.3.0/dt_forge/reactive/rule_engine.py +135 -0
- dt_forge-0.3.0/dt_forge/reactive/rule_repository.py +113 -0
- dt_forge-0.3.0/dt_forge/services/__init__.py +4 -0
- dt_forge-0.3.0/dt_forge/services/api/__init__.py +3 -0
- dt_forge-0.3.0/dt_forge/services/api/app.py +51 -0
- dt_forge-0.3.0/dt_forge/services/api/routes.py +63 -0
- dt_forge-0.3.0/dt_forge/services/api/streaming.py +65 -0
- dt_forge-0.3.0/dt_forge/services/base.py +57 -0
- dt_forge-0.3.0/dt_forge/services/composite.py +35 -0
- dt_forge-0.3.0/dt_forge/services/ditto/__init__.py +4 -0
- dt_forge-0.3.0/dt_forge/services/ditto/client.py +168 -0
- dt_forge-0.3.0/dt_forge/services/ditto/sync.py +69 -0
- dt_forge-0.3.0/dt_forge/session/__init__.py +3 -0
- dt_forge-0.3.0/dt_forge/session/context.py +153 -0
- dt_forge-0.3.0/dt_forge/simulation/__init__.py +16 -0
- dt_forge-0.3.0/dt_forge/simulation/base.py +23 -0
- dt_forge-0.3.0/dt_forge/simulation/discrete_event.py +59 -0
- dt_forge-0.3.0/dt_forge/simulation/forecaster.py +55 -0
- dt_forge-0.3.0/dt_forge/simulation/ode_model.py +78 -0
- dt_forge-0.3.0/dt_forge/simulation/runner.py +105 -0
- dt_forge-0.3.0/dt_forge/simulation/surrogate.py +79 -0
- dt_forge-0.3.0/dt_forge.egg-info/PKG-INFO +44 -0
- dt_forge-0.3.0/dt_forge.egg-info/SOURCES.txt +97 -0
- dt_forge-0.3.0/dt_forge.egg-info/dependency_links.txt +1 -0
- dt_forge-0.3.0/dt_forge.egg-info/entry_points.txt +2 -0
- dt_forge-0.3.0/dt_forge.egg-info/requires.txt +40 -0
- dt_forge-0.3.0/dt_forge.egg-info/top_level.txt +1 -0
- dt_forge-0.3.0/pyproject.toml +81 -0
- dt_forge-0.3.0/setup.cfg +4 -0
dt_forge-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dt-forge
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Domain-agnostic Python framework for digital twin architectures
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: paho-mqtt>=2.0
|
|
7
|
+
Requires-Dist: influxdb-client>=1.40
|
|
8
|
+
Requires-Dist: pymongo>=4.6
|
|
9
|
+
Requires-Dist: motor>=3.3
|
|
10
|
+
Requires-Dist: redis>=5.0
|
|
11
|
+
Requires-Dist: minio>=7.2
|
|
12
|
+
Requires-Dist: pydantic>=2.5
|
|
13
|
+
Requires-Dist: pydantic-settings>=2.1
|
|
14
|
+
Requires-Dist: python-dotenv>=1.0
|
|
15
|
+
Requires-Dist: fastapi>=0.109
|
|
16
|
+
Requires-Dist: uvicorn[standard]>=0.27
|
|
17
|
+
Requires-Dist: httpx>=0.26
|
|
18
|
+
Requires-Dist: sse-starlette>=2.0
|
|
19
|
+
Requires-Dist: scipy>=1.12
|
|
20
|
+
Requires-Dist: numpy>=1.26
|
|
21
|
+
Requires-Dist: simpy>=4.1
|
|
22
|
+
Requires-Dist: onnxruntime>=1.17
|
|
23
|
+
Requires-Dist: scikit-learn>=1.4
|
|
24
|
+
Requires-Dist: pyod>=1.1
|
|
25
|
+
Requires-Dist: prophet>=1.1
|
|
26
|
+
Requires-Dist: transitions>=0.9
|
|
27
|
+
Requires-Dist: simple-pid>=2.0
|
|
28
|
+
Requires-Dist: neo4j>=5.17
|
|
29
|
+
Requires-Dist: vaderSentiment>=3.3
|
|
30
|
+
Requires-Dist: langchain>=0.1
|
|
31
|
+
Requires-Dist: langchain-community>=0.0.20
|
|
32
|
+
Requires-Dist: langchain-openai>=0.0.5
|
|
33
|
+
Requires-Dist: stable-baselines3>=2.2
|
|
34
|
+
Requires-Dist: gymnasium>=0.29
|
|
35
|
+
Requires-Dist: prefect>=2.14
|
|
36
|
+
Requires-Dist: pyyaml>=6.0
|
|
37
|
+
Requires-Dist: click>=8.1
|
|
38
|
+
Requires-Dist: pyserial>=3.5
|
|
39
|
+
Provides-Extra: dev
|
|
40
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
41
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
42
|
+
Requires-Dist: pytest-cov>=4.1; extra == "dev"
|
|
43
|
+
Requires-Dist: ruff>=0.2; extra == "dev"
|
|
44
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""DT-Forge: Domain-agnostic Python framework for digital twin architectures."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from dt_forge.core.base import AbstractDigitalTwin, LayerBase
|
|
6
|
+
from dt_forge.core.config import TwinConfig, SensorFieldSpec
|
|
7
|
+
from dt_forge.core.events import EventBus, DomainEvent
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AbstractDigitalTwin",
|
|
11
|
+
"LayerBase",
|
|
12
|
+
"TwinConfig",
|
|
13
|
+
"SensorFieldSpec",
|
|
14
|
+
"EventBus",
|
|
15
|
+
"DomainEvent",
|
|
16
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from dt_forge.autonomous.base import AbstractAutonomousController
|
|
2
|
+
from dt_forge.autonomous.ooda import OODALoop
|
|
3
|
+
from dt_forge.autonomous.overseer import AutonomousOverseer, OverseerDecision
|
|
4
|
+
from dt_forge.autonomous.planner import GoalPlanner, Goal
|
|
5
|
+
from dt_forge.autonomous.gym_env import GenericTwinEnv
|
|
6
|
+
from dt_forge.autonomous.trainer import PolicyTrainer
|
|
7
|
+
from dt_forge.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
|
+
]
|
|
@@ -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,74 @@
|
|
|
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 dt_forge.autonomous.gym_env import GenericTwinEnv
|
|
12
|
+
from dt_forge.data.storage.base import TimeSeriesStore
|
|
13
|
+
from dt_forge.network.transport import MQTTTransport
|
|
14
|
+
from dt_forge.core.config import TwinConfig
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PolicyDeployer:
|
|
20
|
+
"""
|
|
21
|
+
Loads a trained SB3 policy and applies it in a live twin loop.
|
|
22
|
+
|
|
23
|
+
On each step_once() call:
|
|
24
|
+
1. Reads current observations from InfluxDB
|
|
25
|
+
2. Runs the policy to get an action
|
|
26
|
+
3. Publishes the control command via MQTT
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
policy_path: str,
|
|
32
|
+
config: "TwinConfig",
|
|
33
|
+
ts_store: "TimeSeriesStore",
|
|
34
|
+
mqtt_transport: "MQTTTransport",
|
|
35
|
+
obs_fields: list[str],
|
|
36
|
+
control_field: str,
|
|
37
|
+
ctrl_min: float,
|
|
38
|
+
ctrl_max: float,
|
|
39
|
+
algorithm: str = "SAC",
|
|
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
|
+
_algo_map = {"SAC": SAC, "TD3": TD3, "PPO": PPO, "A2C": A2C}
|
|
52
|
+
AlgoClass = _algo_map.get(algorithm.upper(), SAC)
|
|
53
|
+
self._policy = AlgoClass.load(policy_path)
|
|
54
|
+
log.info("PolicyDeployer loaded '%s' (%s)", policy_path, algorithm)
|
|
55
|
+
|
|
56
|
+
def _get_observation(self) -> np.ndarray:
|
|
57
|
+
return np.array(
|
|
58
|
+
[self._ts.get_latest(f) or 0.0 for f in self._obs_fields],
|
|
59
|
+
dtype=np.float32,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def _scale_action(self, action: np.ndarray) -> float:
|
|
63
|
+
a = float(np.clip(action[0], -1.0, 1.0))
|
|
64
|
+
return self._ctrl_min + (a + 1.0) / 2.0 * (self._ctrl_max - self._ctrl_min)
|
|
65
|
+
|
|
66
|
+
async def step_once(self) -> float:
|
|
67
|
+
obs = self._get_observation()
|
|
68
|
+
action, _ = self._policy.predict(obs, deterministic=True)
|
|
69
|
+
ctrl = self._scale_action(action)
|
|
70
|
+
self._mqtt.publish(
|
|
71
|
+
self._cfg.topic_control,
|
|
72
|
+
{self._control_field: round(ctrl, 4)},
|
|
73
|
+
)
|
|
74
|
+
return ctrl
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""GenericTwinEnv: Gymnasium environment wrapping any TwinModel."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Callable, TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from dt_forge.simulation.base import TwinModel
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class GenericTwinEnv:
|
|
17
|
+
"""
|
|
18
|
+
Gymnasium environment that wraps any TwinModel.
|
|
19
|
+
|
|
20
|
+
Configuration:
|
|
21
|
+
model — TwinModel instance to use as the environment
|
|
22
|
+
control_field — name of the variable the agent adjusts
|
|
23
|
+
process_variable— name of the variable being regulated
|
|
24
|
+
target — desired value for process_variable
|
|
25
|
+
ctrl_min/max — valid range for control output
|
|
26
|
+
obs_fields — field names forming the observation
|
|
27
|
+
obs_low/high — bounds for observation space
|
|
28
|
+
reward_fn — optional custom reward function(obs, target) → float
|
|
29
|
+
max_steps — episode length
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
metadata = {"render_modes": []}
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
model: "TwinModel",
|
|
38
|
+
control_field: str,
|
|
39
|
+
process_variable: str,
|
|
40
|
+
target: float,
|
|
41
|
+
ctrl_min: float,
|
|
42
|
+
ctrl_max: float,
|
|
43
|
+
obs_fields: list[str],
|
|
44
|
+
obs_low: np.ndarray,
|
|
45
|
+
obs_high: np.ndarray,
|
|
46
|
+
reward_fn: Callable | None = None,
|
|
47
|
+
max_steps: int = 500,
|
|
48
|
+
):
|
|
49
|
+
import gymnasium as gym
|
|
50
|
+
from gymnasium import spaces
|
|
51
|
+
|
|
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
|
+
pv = obs.get(f"sim_{self.process_variable}") or obs.get(self.process_variable) or 0.0
|
|
78
|
+
return -abs(pv - target)
|
|
79
|
+
|
|
80
|
+
def _scale_action(self, action: np.ndarray) -> float:
|
|
81
|
+
"""Map [-1, 1] → [ctrl_min, ctrl_max]."""
|
|
82
|
+
a = float(np.clip(action[0], -1.0, 1.0))
|
|
83
|
+
return self.ctrl_min + (a + 1.0) / 2.0 * (self.ctrl_max - self.ctrl_min)
|
|
84
|
+
|
|
85
|
+
def _get_obs(self) -> np.ndarray:
|
|
86
|
+
return np.array(
|
|
87
|
+
[self._last_obs.get(f, 0.0) for f in self.obs_fields],
|
|
88
|
+
dtype=np.float32,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def reset(self, *, seed=None, options=None):
|
|
92
|
+
self.model.reset()
|
|
93
|
+
self._step_count = 0
|
|
94
|
+
self._last_obs = {}
|
|
95
|
+
obs = self._get_obs()
|
|
96
|
+
return obs, {}
|
|
97
|
+
|
|
98
|
+
def step(self, action: np.ndarray):
|
|
99
|
+
ctrl = self._scale_action(action)
|
|
100
|
+
outputs = self.model.step(dt=1.0, inputs={self.control_field: ctrl})
|
|
101
|
+
self._last_obs = outputs
|
|
102
|
+
obs = self._get_obs()
|
|
103
|
+
reward = self.reward_fn(outputs, self.target)
|
|
104
|
+
self._step_count += 1
|
|
105
|
+
terminated = self._step_count >= self.max_steps
|
|
106
|
+
return obs, reward, terminated, False, {}
|
|
107
|
+
|
|
108
|
+
def render(self): ...
|
|
109
|
+
def close(self): ...
|
|
@@ -0,0 +1,176 @@
|
|
|
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 dt_forge.autonomous.base import AbstractAutonomousController
|
|
11
|
+
from dt_forge.core.base import LayerBase
|
|
12
|
+
from dt_forge.core.events import DomainEvent
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from dt_forge.autonomous.deployer import PolicyDeployer
|
|
16
|
+
from dt_forge.autonomous.planner import GoalPlanner
|
|
17
|
+
from dt_forge.core.config import TwinConfig
|
|
18
|
+
from dt_forge.core.events import EventBus
|
|
19
|
+
from dt_forge.data.storage.base import CacheStore, DocumentStore, TimeSeriesStore
|
|
20
|
+
from dt_forge.intelligent.mas import MultiAgentSystem
|
|
21
|
+
from dt_forge.notifications.notifier import HumanNotifier
|
|
22
|
+
from dt_forge.reactive.rule_engine import ThresholdRuleEngine
|
|
23
|
+
from dt_forge.services.ditto.client import DittoClient
|
|
24
|
+
from dt_forge.simulation.base import TwinModel
|
|
25
|
+
|
|
26
|
+
log = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class OODALoop(LayerBase, AbstractAutonomousController):
|
|
30
|
+
"""
|
|
31
|
+
The OODA loop provides full situational awareness and autonomous control.
|
|
32
|
+
|
|
33
|
+
Observe — gather state from all lower layers
|
|
34
|
+
Orient — contextualise using models, history, goals
|
|
35
|
+
Decide — select action plan (rule-based, RL policy, goal planner)
|
|
36
|
+
Act — execute actions on lower layers or via connectors
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
layer_name = "autonomous"
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
config: "TwinConfig",
|
|
44
|
+
event_bus: "EventBus",
|
|
45
|
+
*,
|
|
46
|
+
ts_store: "TimeSeriesStore",
|
|
47
|
+
cache: "CacheStore",
|
|
48
|
+
doc_store: "DocumentStore",
|
|
49
|
+
ditto_client: "DittoClient",
|
|
50
|
+
models: dict[str, "TwinModel"],
|
|
51
|
+
reactive: "ThresholdRuleEngine",
|
|
52
|
+
mas: "MultiAgentSystem",
|
|
53
|
+
connectors: list,
|
|
54
|
+
planner: "GoalPlanner",
|
|
55
|
+
policy: "PolicyDeployer | None" = None,
|
|
56
|
+
loop_interval: int = 5,
|
|
57
|
+
notifier: "HumanNotifier | None" = None,
|
|
58
|
+
):
|
|
59
|
+
super().__init__(config, event_bus)
|
|
60
|
+
self.ts = ts_store
|
|
61
|
+
self.cache = cache
|
|
62
|
+
self.doc = doc_store
|
|
63
|
+
self.ditto = ditto_client
|
|
64
|
+
self.models = models
|
|
65
|
+
self.reactive = reactive
|
|
66
|
+
self.mas = mas
|
|
67
|
+
self.connectors = connectors
|
|
68
|
+
self.planner = planner
|
|
69
|
+
self.policy = policy
|
|
70
|
+
self.loop_interval = loop_interval
|
|
71
|
+
self.notifier = notifier
|
|
72
|
+
|
|
73
|
+
async def observe(self) -> dict:
|
|
74
|
+
base = {
|
|
75
|
+
"state": self.cache.get_state(),
|
|
76
|
+
"health": self.cache.get_latest_cached("health_score"),
|
|
77
|
+
"telemetry": {
|
|
78
|
+
f: self.ts.get_latest(f) for f in self.config.field_names
|
|
79
|
+
},
|
|
80
|
+
"recent_events": self.doc.get_recent_events(5),
|
|
81
|
+
}
|
|
82
|
+
if self.mas:
|
|
83
|
+
base["mas_findings"] = {
|
|
84
|
+
a.agent_name: self.mas.get_agent_detail(a.agent_name)
|
|
85
|
+
for a in self.mas.agents
|
|
86
|
+
}
|
|
87
|
+
return base
|
|
88
|
+
|
|
89
|
+
async def direct_agent(self, agent_name: str, question: str) -> str:
|
|
90
|
+
"""Send a targeted query to a named MAS agent from the autonomous layer."""
|
|
91
|
+
if self.mas is None:
|
|
92
|
+
return "MAS layer not available."
|
|
93
|
+
return await self.mas.ask_agent(agent_name, question)
|
|
94
|
+
|
|
95
|
+
async def orient(self, observation: dict) -> dict:
|
|
96
|
+
return await self.planner.assess(observation)
|
|
97
|
+
|
|
98
|
+
async def decide(self, observation: dict, assessment: dict) -> dict:
|
|
99
|
+
if assessment.get("requires_human_intervention"):
|
|
100
|
+
return {
|
|
101
|
+
"action": "request_human",
|
|
102
|
+
"reason": assessment.get("reason", "intervention required"),
|
|
103
|
+
}
|
|
104
|
+
if assessment.get("requires_shutdown"):
|
|
105
|
+
return {
|
|
106
|
+
"action": "shutdown",
|
|
107
|
+
"reason": assessment.get("reason", "autonomous shutdown"),
|
|
108
|
+
}
|
|
109
|
+
if assessment.get("needs_external_data"):
|
|
110
|
+
return {
|
|
111
|
+
"action": "query_peer",
|
|
112
|
+
"target_twin": assessment.get("target_twin", ""),
|
|
113
|
+
"query": assessment.get("query", {}),
|
|
114
|
+
}
|
|
115
|
+
if self.policy and assessment.get("risk_level") == "low":
|
|
116
|
+
return {"action": "rl_control"}
|
|
117
|
+
return {"action": "maintain_current"}
|
|
118
|
+
|
|
119
|
+
async def act(self, plan: dict) -> None:
|
|
120
|
+
action = plan["action"]
|
|
121
|
+
|
|
122
|
+
if action == "request_human":
|
|
123
|
+
self.doc.log_event(
|
|
124
|
+
"human_intervention_requested", plan, severity="critical"
|
|
125
|
+
)
|
|
126
|
+
await self.bus.publish(
|
|
127
|
+
DomainEvent(
|
|
128
|
+
event_type="autonomous.human_requested",
|
|
129
|
+
source_layer="autonomous",
|
|
130
|
+
source_asset=self.config.asset_id,
|
|
131
|
+
payload=plan,
|
|
132
|
+
severity="critical",
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
self.log.warning("Human intervention requested: %s", plan.get("reason"))
|
|
136
|
+
if self.notifier:
|
|
137
|
+
await self.notifier.send(plan.get("reason", "intervention required"), plan)
|
|
138
|
+
|
|
139
|
+
elif action == "shutdown":
|
|
140
|
+
self.reactive.shutdown_asset() # type: ignore[attr-defined]
|
|
141
|
+
self.doc.log_event("autonomous_shutdown", plan, severity="critical")
|
|
142
|
+
|
|
143
|
+
elif action == "query_peer":
|
|
144
|
+
for conn in self.connectors:
|
|
145
|
+
if conn.can_reach(plan.get("target_twin", "")):
|
|
146
|
+
result = await conn.query(plan["target_twin"], plan.get("query", {}))
|
|
147
|
+
self.doc.log_event("peer_query", {"result": result}, severity="info")
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
elif action == "rl_control":
|
|
151
|
+
if self.policy:
|
|
152
|
+
await self.policy.step_once()
|
|
153
|
+
|
|
154
|
+
async def start(self) -> None:
|
|
155
|
+
self._running = True
|
|
156
|
+
self.log.info("OODA autonomous loop started (interval=%ds)", self.loop_interval)
|
|
157
|
+
while self._running:
|
|
158
|
+
try:
|
|
159
|
+
obs = await self.observe()
|
|
160
|
+
assessment = await self.orient(obs)
|
|
161
|
+
plan = await self.decide(obs, assessment)
|
|
162
|
+
await self.act(plan)
|
|
163
|
+
try:
|
|
164
|
+
# Publish a compact summary for the dashboard and monitoring tools.
|
|
165
|
+
# Wrapped in try/except so a Redis outage cannot kill the control loop.
|
|
166
|
+
self.cache.set_latest("ooda_last_cycle", {
|
|
167
|
+
"action": plan.get("action", "unknown"),
|
|
168
|
+
"reason": plan.get("reason", ""),
|
|
169
|
+
"risk_level": assessment.get("risk_level", ""),
|
|
170
|
+
"ts_s": int(time.time()),
|
|
171
|
+
})
|
|
172
|
+
except Exception:
|
|
173
|
+
pass
|
|
174
|
+
except Exception as e:
|
|
175
|
+
self.log.error("OODA loop error: %s", e)
|
|
176
|
+
await asyncio.sleep(self.loop_interval)
|