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.
Files changed (99) hide show
  1. dt_forge-0.3.0/PKG-INFO +44 -0
  2. dt_forge-0.3.0/dt_forge/__init__.py +16 -0
  3. dt_forge-0.3.0/dt_forge/autonomous/__init__.py +19 -0
  4. dt_forge-0.3.0/dt_forge/autonomous/base.py +29 -0
  5. dt_forge-0.3.0/dt_forge/autonomous/deployer.py +74 -0
  6. dt_forge-0.3.0/dt_forge/autonomous/gym_env.py +109 -0
  7. dt_forge-0.3.0/dt_forge/autonomous/ooda.py +176 -0
  8. dt_forge-0.3.0/dt_forge/autonomous/overseer.py +255 -0
  9. dt_forge-0.3.0/dt_forge/autonomous/planner.py +73 -0
  10. dt_forge-0.3.0/dt_forge/autonomous/trainer.py +67 -0
  11. dt_forge-0.3.0/dt_forge/cli/__init__.py +1 -0
  12. dt_forge-0.3.0/dt_forge/cli/main.py +268 -0
  13. dt_forge-0.3.0/dt_forge/collection/__init__.py +15 -0
  14. dt_forge-0.3.0/dt_forge/collection/aggregate.py +132 -0
  15. dt_forge-0.3.0/dt_forge/collection/base.py +77 -0
  16. dt_forge-0.3.0/dt_forge/collection/collection_twin.py +107 -0
  17. dt_forge-0.3.0/dt_forge/collection/composite.py +144 -0
  18. dt_forge-0.3.0/dt_forge/collection/network_twin.py +189 -0
  19. dt_forge-0.3.0/dt_forge/collection/orchestrator.py +39 -0
  20. dt_forge-0.3.0/dt_forge/connector/__init__.py +12 -0
  21. dt_forge-0.3.0/dt_forge/connector/api_connector.py +62 -0
  22. dt_forge-0.3.0/dt_forge/connector/base.py +71 -0
  23. dt_forge-0.3.0/dt_forge/connector/ditto_connector.py +77 -0
  24. dt_forge-0.3.0/dt_forge/connector/mqtt_connector.py +83 -0
  25. dt_forge-0.3.0/dt_forge/core/__init__.py +18 -0
  26. dt_forge-0.3.0/dt_forge/core/base.py +95 -0
  27. dt_forge-0.3.0/dt_forge/core/config.py +153 -0
  28. dt_forge-0.3.0/dt_forge/core/events.py +74 -0
  29. dt_forge-0.3.0/dt_forge/core/lifecycle.py +65 -0
  30. dt_forge-0.3.0/dt_forge/core/registry.py +77 -0
  31. dt_forge-0.3.0/dt_forge/core/types.py +57 -0
  32. dt_forge-0.3.0/dt_forge/data/__init__.py +13 -0
  33. dt_forge-0.3.0/dt_forge/data/management/__init__.py +4 -0
  34. dt_forge-0.3.0/dt_forge/data/management/health.py +51 -0
  35. dt_forge-0.3.0/dt_forge/data/management/pipeline.py +96 -0
  36. dt_forge-0.3.0/dt_forge/data/storage/__init__.py +16 -0
  37. dt_forge-0.3.0/dt_forge/data/storage/base.py +84 -0
  38. dt_forge-0.3.0/dt_forge/data/storage/influx.py +136 -0
  39. dt_forge-0.3.0/dt_forge/data/storage/minio_store.py +84 -0
  40. dt_forge-0.3.0/dt_forge/data/storage/mongo.py +94 -0
  41. dt_forge-0.3.0/dt_forge/data/storage/postgres.py +47 -0
  42. dt_forge-0.3.0/dt_forge/data/storage/provenance.py +58 -0
  43. dt_forge-0.3.0/dt_forge/data/storage/redis_store.py +65 -0
  44. dt_forge-0.3.0/dt_forge/data/text_ingestor.py +140 -0
  45. dt_forge-0.3.0/dt_forge/data/writer.py +103 -0
  46. dt_forge-0.3.0/dt_forge/infra/__init__.py +4 -0
  47. dt_forge-0.3.0/dt_forge/infra/docker.py +350 -0
  48. dt_forge-0.3.0/dt_forge/infra/health_check.py +95 -0
  49. dt_forge-0.3.0/dt_forge/intelligent/__init__.py +15 -0
  50. dt_forge-0.3.0/dt_forge/intelligent/agent.py +150 -0
  51. dt_forge-0.3.0/dt_forge/intelligent/base.py +42 -0
  52. dt_forge-0.3.0/dt_forge/intelligent/knowledge_graph.py +184 -0
  53. dt_forge-0.3.0/dt_forge/intelligent/mas.py +225 -0
  54. dt_forge-0.3.0/dt_forge/intelligent/tools.py +100 -0
  55. dt_forge-0.3.0/dt_forge/ml/__init__.py +3 -0
  56. dt_forge-0.3.0/dt_forge/ml/corpus.py +85 -0
  57. dt_forge-0.3.0/dt_forge/network/__init__.py +4 -0
  58. dt_forge-0.3.0/dt_forge/network/ingestor.py +96 -0
  59. dt_forge-0.3.0/dt_forge/network/transport.py +98 -0
  60. dt_forge-0.3.0/dt_forge/notifications/__init__.py +3 -0
  61. dt_forge-0.3.0/dt_forge/notifications/notifier.py +103 -0
  62. dt_forge-0.3.0/dt_forge/physical/__init__.py +4 -0
  63. dt_forge-0.3.0/dt_forge/physical/adapters/__init__.py +1 -0
  64. dt_forge-0.3.0/dt_forge/physical/base.py +34 -0
  65. dt_forge-0.3.0/dt_forge/physical/simulator.py +107 -0
  66. dt_forge-0.3.0/dt_forge/reactive/__init__.py +16 -0
  67. dt_forge-0.3.0/dt_forge/reactive/actions.py +50 -0
  68. dt_forge-0.3.0/dt_forge/reactive/base.py +31 -0
  69. dt_forge-0.3.0/dt_forge/reactive/fsm_engine.py +204 -0
  70. dt_forge-0.3.0/dt_forge/reactive/pid.py +94 -0
  71. dt_forge-0.3.0/dt_forge/reactive/rule_engine.py +135 -0
  72. dt_forge-0.3.0/dt_forge/reactive/rule_repository.py +113 -0
  73. dt_forge-0.3.0/dt_forge/services/__init__.py +4 -0
  74. dt_forge-0.3.0/dt_forge/services/api/__init__.py +3 -0
  75. dt_forge-0.3.0/dt_forge/services/api/app.py +51 -0
  76. dt_forge-0.3.0/dt_forge/services/api/routes.py +63 -0
  77. dt_forge-0.3.0/dt_forge/services/api/streaming.py +65 -0
  78. dt_forge-0.3.0/dt_forge/services/base.py +57 -0
  79. dt_forge-0.3.0/dt_forge/services/composite.py +35 -0
  80. dt_forge-0.3.0/dt_forge/services/ditto/__init__.py +4 -0
  81. dt_forge-0.3.0/dt_forge/services/ditto/client.py +168 -0
  82. dt_forge-0.3.0/dt_forge/services/ditto/sync.py +69 -0
  83. dt_forge-0.3.0/dt_forge/session/__init__.py +3 -0
  84. dt_forge-0.3.0/dt_forge/session/context.py +153 -0
  85. dt_forge-0.3.0/dt_forge/simulation/__init__.py +16 -0
  86. dt_forge-0.3.0/dt_forge/simulation/base.py +23 -0
  87. dt_forge-0.3.0/dt_forge/simulation/discrete_event.py +59 -0
  88. dt_forge-0.3.0/dt_forge/simulation/forecaster.py +55 -0
  89. dt_forge-0.3.0/dt_forge/simulation/ode_model.py +78 -0
  90. dt_forge-0.3.0/dt_forge/simulation/runner.py +105 -0
  91. dt_forge-0.3.0/dt_forge/simulation/surrogate.py +79 -0
  92. dt_forge-0.3.0/dt_forge.egg-info/PKG-INFO +44 -0
  93. dt_forge-0.3.0/dt_forge.egg-info/SOURCES.txt +97 -0
  94. dt_forge-0.3.0/dt_forge.egg-info/dependency_links.txt +1 -0
  95. dt_forge-0.3.0/dt_forge.egg-info/entry_points.txt +2 -0
  96. dt_forge-0.3.0/dt_forge.egg-info/requires.txt +40 -0
  97. dt_forge-0.3.0/dt_forge.egg-info/top_level.txt +1 -0
  98. dt_forge-0.3.0/pyproject.toml +81 -0
  99. dt_forge-0.3.0/setup.cfg +4 -0
@@ -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)