# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
# SPDX-License-Identifier: MPL-2.0
from __future__ import annotations
from typing import List, Dict, Any, Tuple, TYPE_CHECKING
from VeraGridEngine.Devices.multi_circuit import MultiCircuit
from VeraGridEngine.Devices.Diagrams.base_diagram import copy_diagrams
from VeraGridEngine.enumerations import SimulationTypes
if TYPE_CHECKING:
from VeraGridEngine.Simulations.types import DRIVER_OBJECTS
from VeraGridEngine.Simulations.driver_template import DriverToSave
def _copy_diagrams_for_circuit(diagrams: list[Any], circuit: MultiCircuit) -> list[Any]:
"""
Copy diagrams and rebind their API object pointers to a target circuit.
"""
obj_dict = circuit.get_all_elements_dict_by_type(add_locations=True)
return copy_diagrams(diagrams=diagrams, obj_dict=obj_dict)
[docs]
class ScenarioNode:
__slots__ = (
"node_id",
"circuit",
"diagrams",
"parent",
"children",
"drivers"
)
def __init__(
self,
node_id: int,
data: MultiCircuit,
diagrams: list[Any] | None = None,
parent: ScenarioNode | None = None,
children: list[ScenarioNode] | None = None
) -> None:
"""
Initializes a ScenarioNode with a unique identifier and its associated circuit data.
The meaning of *data* depends on the node's position in the tree:
- **Root node** (``parent is None``): *data* holds the full authoritative
``MultiCircuit``. User edits are applied directly to this object.
- **Non-root node**: *data* holds the **delta** β a ``MultiCircuit`` produced
by ``differentiate_circuits()`` that captures only the changes relative to
the parent's composed circuit. The full circuit for this node is always
reconstructed on demand via ``MultiVerse.checkout()`` and kept in
``MultiVerse._current_model`` while this node is active.
:param node_id: A unique identifier for the tree node.
:param data: Full circuit (root) or delta circuit (non-root).
:param parent: Parent node, or ``None`` for root nodes.
"""
self.node_id: int = node_id
self.circuit: MultiCircuit = data
self.diagrams: list[Any] = copy_diagrams(diagrams if diagrams is not None else data.diagrams)
self.parent: ScenarioNode | None = parent
self.children: list[ScenarioNode] = children if children is not None else list()
self.drivers: Dict[SimulationTypes, DRIVER_OBJECTS] = dict()
[docs]
def get_drivers_to_save(self)-> List[DriverToSave]:
data = list()
for tpe, drv in self.drivers.items():
data.append(drv.get_save_data())
return data
[docs]
def append_child(self, child: ScenarioNode) -> None:
"""
:param child:
:return:
"""
child.parent = self
self.children.append(child)
[docs]
def insert_child(self, position: int, child: ScenarioNode) -> None:
"""
:param position:
:param child:
:return:
"""
child.parent = self
self.children.insert(position, child)
[docs]
def remove_child(self, child: ScenarioNode) -> None:
"""
:param child:
:return:
"""
self.children.remove(child)
child.parent = None
[docs]
def remove_child_at(self, position: int) -> ScenarioNode:
"""
:param position:
:return:
"""
child: ScenarioNode = self.children.pop(position)
child.parent = None
return child
[docs]
def child(self, row: int) -> ScenarioNode:
"""
:param row:
:return:
"""
return self.children[row]
[docs]
def child_count(self) -> int:
"""
:return:
"""
return len(self.children)
[docs]
def row(self) -> int:
if self.parent is None:
return 0
return self.parent.children.index(self)
[docs]
class MultiVerse:
__slots__ = (
"_root_nodes",
"_nodes_by_id",
"_next_id",
"_base_model",
"_current_model",
"_current_node",
)
def __init__(self, current_model: MultiCircuit | None = None) -> None:
"""
Initializes an instance of the class with the specified model and necessary
attributes for processing scenario nodes and model comparison.
Automatically creates a root scenario node from the current_model.
If the current_model has an empty name, it is set to "Base Scenario".
:param current_model: The currently edited and used model. It is used as the currently edited model
:type current_model: MultiCircuit
"""
self._root_nodes: List[ScenarioNode] = []
self._nodes_by_id: Dict[int, ScenarioNode] = {}
self._next_id: int = 1
# Model to compare the current model against (kept for backwards compatibility)
self._base_model: MultiCircuit | None = current_model
# Currently edited and used model (acts as the place to compose the model of a delta)
self._current_model: MultiCircuit | None = current_model
# Currently active scenario node
self._current_node: ScenarioNode | None = None
# Set a default name if the current model has an empty name
if current_model is not None:
if current_model.name is None or current_model.name.strip() == "":
current_model.name = "Base Scenario"
# Create the initial root scenario node from the current model
root_node = self.create_node(data=current_model, parent_id=None, position=0)
self._current_node = root_node
@property
def base_model(self) -> MultiCircuit:
return self._base_model
@base_model.setter
def base_model(self, val: MultiCircuit):
if isinstance(val, MultiCircuit):
self._base_model: MultiCircuit = val
elif val is None:
self._base_model = None
else:
raise ValueError("Base model must be a MultiCircuit object")
@property
def current_model(self) -> MultiCircuit:
return self._current_model
@current_model.setter
def current_model(self, val: MultiCircuit):
if isinstance(val, MultiCircuit):
self._current_model: MultiCircuit = val
else:
raise ValueError("Current model must be a MultiCircuit object")
@property
def current_node(self) -> ScenarioNode:
return self._current_node
@property
def root_nodes(self) -> List[ScenarioNode]:
return self._root_nodes
def _generate_id(self) -> int:
node_id: int = self._next_id
self._next_id += 1
return node_id
[docs]
@staticmethod
def checkout(node: ScenarioNode) -> MultiCircuit:
"""
Reconstruct the full MultiCircuit for *node* by replaying its delta chain from the root.
Always returns a **fresh copy** β safe to mutate without affecting stored data.
- Root nodes: returns ``root.data.copy()``.
- Non-root nodes: walks root β β¦ β node, copies the root circuit, then applies each
ancestor's delta (``node.data``) in order via ``merge_circuit()``.
:param node: Target ScenarioNode
:return: Fully composed MultiCircuit for that node
"""
if node.parent is None:
composed = node.circuit.copy()
composed.diagrams = _copy_diagrams_for_circuit(diagrams=node.diagrams, circuit=composed)
return composed
# Collect the path from this node up to (but not including) the root
chain: list[ScenarioNode] = []
current: ScenarioNode = node
while current.parent is not None:
chain.append(current)
current = current.parent
root: ScenarioNode = current
chain.reverse() # root-child β β¦ β node
# Start from a copy of the root's authoritative circuit
composed: MultiCircuit = root.circuit.copy()
# Apply each delta going down the chain (node.data IS the delta for non-root nodes)
for step in chain:
composed.merge_circuit(new_grid=step.circuit)
# Preserve the target scenario's name (composed inherits root's name otherwise)
composed.name = node.circuit.name
composed.diagrams = _copy_diagrams_for_circuit(diagrams=node.diagrams, circuit=composed)
return composed
def _compose_node(self, node: ScenarioNode) -> MultiCircuit:
"""
Return the full scenario represented by *node*.
This is the single semantic rule for node composition:
- roots expose their authoritative circuit directly
- non-roots are reconstructed from their delta chain
"""
if node.parent is None:
node.circuit.diagrams = _copy_diagrams_for_circuit(diagrams=node.diagrams, circuit=node.circuit)
return node.circuit
return self.checkout(node)
def _build_delta_from_composed(self, node: ScenarioNode, composed: MultiCircuit) -> MultiCircuit:
"""
Convert a fully composed scenario back into the storage representation of *node*.
Root nodes store the full authoritative circuit, while non-root nodes store only
the delta against their parent. Diagrams remain full scenario-owned copies in both cases.
"""
scenario_name: str = node.circuit.name
composed.name = scenario_name
if node.parent is None:
composed.diagrams = _copy_diagrams_for_circuit(diagrams=composed.diagrams, circuit=composed)
return composed
parent_circuit: MultiCircuit = self.checkout(node.parent)
_ok, _logger, delta = composed.differentiate_circuits(
base_grid=parent_circuit,
force_second_pass=True,
)
delta.name = scenario_name
delta.diagrams = _copy_diagrams_for_circuit(diagrams=composed.diagrams, circuit=composed)
return delta
def _store_node_circuit_and_diagrams(self, node: ScenarioNode, stored_circuit: MultiCircuit) -> None:
"""
Persist a node's storage payload and keep node-owned diagrams in sync with it.
"""
node.circuit = stored_circuit
node.diagrams = copy_diagrams(stored_circuit.diagrams)
def _store_composed_node_data(self, node: ScenarioNode, composed: MultiCircuit) -> None:
"""
Persist a fully composed circuit back into the node storage model.
Root nodes keep the full authoritative circuit.
Non-root nodes store only the delta against their parent.
"""
stored_circuit = self._build_delta_from_composed(node=node, composed=composed)
self._store_node_circuit_and_diagrams(node=node, stored_circuit=stored_circuit)
if node.parent is None and node in self._root_nodes:
self._base_model = node.circuit
def _set_active_node(self, node: ScenarioNode) -> MultiCircuit:
"""
Make *node* the active scenario and return the active circuit object.
The activation rule is centralized here so all code paths agree on:
- which object becomes ``current_model``
- when roots expose their authoritative circuit directly
- how scenario-owned diagrams are copied into the active circuit
"""
composed = self._compose_node(node)
composed.diagrams = _copy_diagrams_for_circuit(diagrams=node.diagrams, circuit=composed)
self._current_node = node
self._current_model = composed
return composed
def _set_fallback_active_node(self, fallback_node: ScenarioNode | None) -> None:
"""
Activate a fallback node or clear active state if the tree became empty.
"""
if fallback_node is None:
self._current_node = None
self._current_model = None
self._base_model = None
return
self._set_active_node(fallback_node)
if self._root_nodes:
self._base_model = self._root_nodes[0].circuit
[docs]
def commit_current(self) -> None:
"""
Persist edits made to the currently active scenario.
- **Root**: no structural delta is computed because ``root.data`` is authoritative
and edited in-place. We only synchronize scenario-owned diagrams.
- **Non-root**: computes ``differentiate_circuits(current_model, parent_composed)``
and stores the result back into ``node.data``, replacing the previous delta.
The scenario name is preserved on the new delta.
"""
if self._current_node is None:
return
if self._current_node.parent is None:
# Keep root node diagram snapshots aligned with in-place edits without running
# the expensive root storage/diff path.
self._current_node.diagrams = copy_diagrams(self._current_model.diagrams)
self._base_model = self._current_node.circuit
return
self._store_composed_node_data(node=self._current_node, composed=self._current_model)
[docs]
def merge_children_into_parent(self, parent_id: int) -> ScenarioNode:
"""
Merge all direct children into the specified parent node.
Child deltas are merged into the parent in the current sibling order.
After the merge:
- the parent keeps the merged scenario
- direct child nodes are removed
- former grandchildren are rebased and attached directly under the parent
If several children modify the same objects, later children win because they
are merged after earlier siblings.
"""
self.commit_current()
parent: ScenarioNode = self.get_node(parent_id)
direct_children: list[ScenarioNode] = list(parent.children)
if not direct_children:
if self._current_node is parent:
self._current_model = parent.circuit if parent.parent is None else self.checkout(parent)
return parent
merged_parent_model: MultiCircuit = self.checkout(parent)
for child in direct_children:
merged_parent_model.merge_circuit(new_grid=child.circuit)
merged_parent_model.diagrams = _copy_diagrams_for_circuit(
diagrams=child.diagrams,
circuit=merged_parent_model,
)
rebased_grandchildren: list[tuple[ScenarioNode, MultiCircuit]] = list()
for child in direct_children:
for grandchild in child.children:
rebased_grandchildren.append((grandchild, self.checkout(grandchild)))
self._store_composed_node_data(parent, merged_parent_model)
reparented_children: list[ScenarioNode] = list()
for grandchild, full_grid in rebased_grandchildren:
delta = self._build_delta_from_composed(node=grandchild, composed=full_grid)
self._store_node_circuit_and_diagrams(node=grandchild, stored_circuit=delta)
grandchild.parent = None
reparented_children.append(grandchild)
parent.children = list()
for child in direct_children:
child.parent = None
child.children = list()
if child.node_id in self._nodes_by_id:
del self._nodes_by_id[child.node_id]
for grandchild in reparented_children:
parent.append_child(grandchild)
# After a merge, the direct child scenarios cease to exist as standalone choices.
# Promote the merge target to the active scenario so both engine state and GUI state
# converge on the surviving node that now owns the merged result.
self._set_active_node(parent)
return parent
[docs]
def activate_scenario(self, node_id: int) -> MultiCircuit:
"""
Switch the active scenario to the node identified by *node_id*.
1. Commits the current node's unsaved edits (non-root only).
2. Composes the target node's full circuit via ``checkout()``.
3. Updates ``current_model`` and ``current_node``.
Root nodes use ``root.data`` directly so in-place edits are reflected immediately.
Non-root nodes always get a fresh composed copy.
:param node_id: ID of the node to activate
:return: The MultiCircuit that is now active
"""
target: ScenarioNode = self.get_node(node_id)
self.commit_current()
return self._set_active_node(target)
[docs]
def create_node(
self,
data: MultiCircuit,
parent_id: int | None = None,
position: int | None = None,
) -> ScenarioNode:
"""
:param data:
:param parent_id:
:param position:
:return:
"""
parent: ScenarioNode | None = None
node_diagrams: list[Any] | None = None
if parent_id is not None:
parent = self.get_node(parent_id)
# ScenarioNode performs the deep copy; avoid copying twice here.
node_diagrams = parent.diagrams
node: ScenarioNode = ScenarioNode(
node_id=self._generate_id(),
data=data,
diagrams=node_diagrams,
parent=parent,
)
if parent is None:
if position is None:
self._root_nodes.append(node)
else:
self._root_nodes.insert(position, node)
else:
if position is None:
parent.append_child(node)
else:
parent.insert_child(position, node)
self._nodes_by_id[node.node_id] = node
return node
[docs]
def roots_number(self):
return len(self._root_nodes)
[docs]
def check_node(self, node_id: int) -> bool:
return node_id in self._nodes_by_id
[docs]
def get_node(self, node_id: int) -> ScenarioNode:
"""
:param node_id:
:return:
"""
if node_id not in self._nodes_by_id:
raise KeyError(f"Node with id {node_id} does not exist")
return self._nodes_by_id[node_id]
[docs]
def set_node(self, node_id: int, node: ScenarioNode):
"""
:param node_id:
:param node:
:return:
"""
self._nodes_by_id[node_id] = node
[docs]
def insert_node(self, position: int, node: ScenarioNode):
"""
:param position:
:param node:
:return:
"""
self._root_nodes.insert(position, node)
[docs]
def exists(self, node_id: int) -> bool:
"""
:param node_id:
:return:
"""
return node_id in self._nodes_by_id
[docs]
def update_node_data(self, node_id: int, data: MultiCircuit) -> ScenarioNode:
"""
:param node_id:
:param data:
:return:
"""
node: ScenarioNode = self.get_node(node_id)
node.circuit = data
return node
[docs]
def delete_node(self, node_id: int) -> ScenarioNode:
"""
Delete a node and its entire subtree from the multiverse.
The non-obvious part of this operation is active-state repair. The GUI and the save
path both assume that ``current_node`` and ``current_model`` always point to a valid
scenario still owned by this tree. If the deleted subtree contains the active node,
leaving those references untouched would keep them pointing at detached objects that are
no longer in ``_nodes_by_id`` nor reachable from any root.
The fallback policy is:
- if the deleted subtree had a parent, activate that parent
- otherwise, activate the first remaining root if one exists
- otherwise, clear current/base state because the multiverse became empty
:param node_id:
:return:
"""
node: ScenarioNode = self.get_node(node_id)
parent_fallback: ScenarioNode | None = node.parent
if node.parent is None and len(self._root_nodes) == 1:
raise ValueError("The last root scenario cannot be deleted")
if node.parent is None:
self._root_nodes.remove(node)
else:
node.parent.remove_child(node)
deleted_active_subtree = self._is_descendant(candidate_parent=node, candidate_child=self._current_node)
self._delete_from_registry_recursive(node)
# Deletion should always leave the multiverse focused on a surviving scenario.
# Prefer the deleted node's parent when it exists. If a root was deleted, fall back
# to the first remaining root.
fallback_node: ScenarioNode | None = parent_fallback
if fallback_node is None and self._root_nodes:
fallback_node = self._root_nodes[0]
if deleted_active_subtree:
self._set_fallback_active_node(fallback_node)
return node
def _delete_from_registry_recursive(self, node: ScenarioNode) -> None:
"""
:param node:
:return:
"""
for child in node.children:
self._delete_from_registry_recursive(child)
node.children.clear()
if node.node_id in self._nodes_by_id:
del self._nodes_by_id[node.node_id]
[docs]
def move_node(
self,
node_id: int,
new_parent_id: int | None,
position: int | None = None,
) -> ScenarioNode:
"""
:param node_id:
:param new_parent_id:
:param position:
:return:
"""
raise NotImplementedError("Scenario reparenting is disabled until delta rebasing is implemented")
def _is_descendant(self,
candidate_parent: ScenarioNode,
candidate_child: ScenarioNode | None) -> bool:
"""
:param candidate_parent:
:param candidate_child:
:return:
"""
current: ScenarioNode | None = candidate_child
while current is not None:
if current is candidate_parent:
return True
current = current.parent
return False
[docs]
def clear(self) -> None:
"""
:return:
"""
self._root_nodes.clear()
self._nodes_by_id.clear()
self._next_id = 1
[docs]
def iter_nodes_depth_first(self) -> List[ScenarioNode]:
"""
:return:
"""
result: List[ScenarioNode] = list()
for root in self._root_nodes:
self._collect_subtree_depth_first(root, result)
return result
def _collect_subtree_depth_first(self,
node: ScenarioNode,
result: List[ScenarioNode]) -> None:
"""
:param node:
:param result:
:return:
"""
result.append(node)
child: ScenarioNode
for child in node.children:
self._collect_subtree_depth_first(child, result)
[docs]
def get_sorted_node_data(self) -> List[MultiCircuit]:
"""
Deterministic preorder traversal.
Good if you only want the MultiCircuit objects in a stable order.
"""
nodes: List[ScenarioNode] = self.iter_nodes_depth_first()
return [node.circuit for node in nodes]
[docs]
def to_record_list(self) -> List[Dict[str, Any]]:
"""
Export full tree structure as a flat ordered list.
This is the correct format for JSON if you want to rebuild the tree later.
"""
records: List[Dict[str, Any]] = list()
for node in self.iter_nodes_depth_first():
parent_id: int | None = None if node.parent is None else node.parent.node_id
position: int = 0 if node.parent is None else node.row()
records.append(
{
"node_id": node.node_id,
"parent_id": parent_id,
"position": position,
"idtag": node.circuit.idtag, # MultiCircuit Idtag
}
)
return records
[docs]
def get_save_data(self) -> Tuple[Dict[str, Dict[str, str]],
Dict[str, MultiCircuit],
Dict[str, List[DriverToSave]]]:
"""
:return:
"""
multiverse_node_data: Dict[str, Dict[str, str]] = dict()
multiverse_grid_data: Dict[str, MultiCircuit] = dict()
multiverse_drivers_data: Dict[str, List[DriverToSave]] = dict()
for node in self.iter_nodes_depth_first():
multiverse_node_data[node.node_id] = {
"node_id": node.node_id,
"circuit_idtag": node.circuit.idtag,
"parent_id": node.parent.node_id if node.parent is not None else None,
"position": self._root_nodes.index(node) if node.parent is None else node.row(),
"children": [chl.node_id for chl in node.children],
}
# we want to store all those models that are not the root
# if node.data != base_circuit:
multiverse_grid_data[node.circuit.idtag] = node.circuit
multiverse_drivers_data[node.circuit.idtag] = node.get_drivers_to_save()
multiverse_meta_data = {
"active_node_id": None if self._current_node is None else self._current_node.node_id,
"nodes": multiverse_node_data,
}
return multiverse_meta_data, multiverse_grid_data, multiverse_drivers_data
[docs]
def parse_json(self,
diffs_dict: Dict[str, MultiCircuit],
metadata: Dict[str, Dict[str | int | float] | int | None]):
"""
Parse the json metadata to fill this object
:param diffs_dict:
:param metadata: json file name or zip file pointer
:return:
"""
self.clear()
self._base_model = None
self._current_model = None
self._current_node = None
built_nodes: dict[int, ScenarioNode] = dict()
max_id: int = 0
# Build nodes from parent links; metadata may be serialized as a dict with string keys.
if "nodes" in metadata and isinstance(metadata["nodes"], dict):
node_metadata = metadata["nodes"]
else:
node_metadata = metadata
pending: Dict[int, Dict[str | int | float]] = {
int(node_id): record for node_id, record in node_metadata.items()
}
while pending:
progressed: bool = False
for metadata_node_id in list(pending.keys()):
record = pending[metadata_node_id]
node_id: int = int(record["node_id"])
circuit_idtag: str = str(record["circuit_idtag"])
parent_id_raw = record["parent_id"]
parent_id: int | None = None if parent_id_raw is None else int(parent_id_raw)
if parent_id is not None and parent_id not in built_nodes:
continue
parent: ScenarioNode | None = None if parent_id is None else built_nodes[parent_id]
position_raw = record.get("position", None)
position = None if position_raw is None else int(position_raw)
data = diffs_dict.get(circuit_idtag, None)
if data is not None:
node = ScenarioNode(
node_id=node_id,
data=data,
diagrams=data.diagrams,
parent=parent,
)
if parent is None:
if position is None:
self._root_nodes.append(node)
else:
self._root_nodes.insert(position, node)
else:
if position is None:
parent.append_child(node)
else:
parent.insert_child(position, node)
self.set_node(node_id, node)
built_nodes[node_id] = node
del pending[metadata_node_id]
progressed = True
if node_id > max_id:
max_id = node_id
else:
pass
if not progressed:
unresolved = ", ".join(str(node_id) for node_id in pending.keys())
raise ValueError(f"Could not resolve multiverse metadata parent links for nodes: {unresolved}")
self._next_id = max_id + 1
if self._root_nodes:
active_node_id_raw = metadata.get("active_node_id", None) if isinstance(metadata, dict) else None
if active_node_id_raw is None:
active_node = self._root_nodes[0]
else:
active_node = self.get_node(int(active_node_id_raw))
self._set_active_node(active_node)
self._base_model = self._root_nodes[0].circuit