diff --git a/.gitignore b/.gitignore index b2de12ef6..5ff2d8ada 100644 --- a/.gitignore +++ b/.gitignore @@ -129,3 +129,5 @@ dmypy.json /todo.md test/resources/test-network-database.txt + +*.iml diff --git a/changelog.md b/changelog.md index c112b7ddf..a6cfb13f0 100644 --- a/changelog.md +++ b/changelog.md @@ -2,15 +2,45 @@ ## [0.48.0] - UNRELEASED ### Breaking Changes * Updated to new Tracing API. All old traces will need to be re-written with the new API. +* `AcLineSegment` supports adding a maximum of 2 terminals. Mid-span terminals are no longer supported and models should migrate to using `Clamp`. +* `Clamp` supports only adding a single terminal. +* `FeederDirectionStateOperations` have been reworked to take `NetworkStateOperators` as a parameter. +* `RemoveDirection` has been removed. It did not work reliably with dual fed networks with loops. You now need to clear direction using the new +`ClearDirection` and reapply directions where appropriate using `SetDirection`. +* `Cut` supports adding a maximum of 2 terminals. + ### New Features -* None. +* Added `ClearDirection` that clears feeder directions. ### Enhancements -* None. +* Tracing models with `Cut` and `Clamp` are now supported via the new tracing API. +* Added support to `TestNetworkBuilder` for: + * `with_clamp` - Adds a clamp to the previously added `AcLineSegment` + * `with_cut` - Adds a cut to the previously added `AcLineSegment` + * `connect_to` - Connects the previously added item, rather than having to specify it again in `connect`. + * You can now add sites to the `TestNetworkBuilder` via `addSite`. + * You can now add busbar sections natively with `from_busbar_section` and `to_busbar_section` + * The prefix for generated mRIDs for "other" equipment can be specified with the `default_mrid_prefix` argument in `from_other` and `to_other`. +* When processing feeder assignments, all LV feeders belonging to a dist substation site will now be considered energized when the site is energized by a feeder. + + ### Fixes - When finding `LvFeeders` in the `Site` we will now exclude `LvFeeders` that start with an open `Switch` +* When finding `LvFeeders` in the `Site` we will now exclude `LvFeeders` that start with an open `Switch` +* `AssignToFeeder` and `AssignToLvFeeder` will no longer trace from start terminals that belong to open switches +* The follow fixes were added to Traversal and NetworkTrace: + * `can_stop_on_start_item` now works for branching traversals. + * Traversal start items are added to the queue before traversal starts, so that the start items honour the queue type order. + * Stop conditions on the `NetworkTrace` now are checked based on a step type, like `QueueCondition` does, rather than by checking `can_action_item`. + * `Cut` and `Clamp` are now correctly supported in `SetDirection` and `DirectionCondition`. + * `NetworkTrace` now handles starting on `Cut` , `Clamp`, and `AcLineSegment` and their terminals in a explicit / sensible way. + * `NetworkTracePathProvider` now correctly handles next paths when starting on a `Clamp` terminal. +* `NetworkTrace`/`Traversal` now correctly respects `can_stop_on_start_item` when providing multiple start items. +* `AssignToFeeders`/`AssignToLvFeeders` now finds back-fed equipment correctly +* `AssignToFeeders` and `AssignToLvFeeders` will now associate `PowerElectronicUnits` with their `powerElectronicsConnection` `Feeder`/`LvFeeder`. + + ### Notes * None. diff --git a/setup.py b/setup.py index a90aad7dd..43d141980 100644 --- a/setup.py +++ b/setup.py @@ -18,6 +18,7 @@ "pytest-cov==2.10.1", "pytest-asyncio==0.19.0", "pytest-timeout==1.4.2", + 'pytest-subtests', "hypothesis==6.56.3", "grpcio-testing==1.61.3", "pylint==2.14.5", diff --git a/src/zepben/evolve/__init__.py b/src/zepben/evolve/__init__.py index d9c9e85a2..8e5507d11 100644 --- a/src/zepben/evolve/__init__.py +++ b/src/zepben/evolve/__init__.py @@ -149,10 +149,7 @@ from zepben.evolve.model.phases import * from zepben.evolve.model.resistance_reactance import * -from zepben.evolve.services.network.tracing.traversal.traversal import * -from zepben.evolve.services.network.tracing.traversal.queue import * -from zepben.evolve.services.network.tracing.feeder.feeder_direction import * from zepben.evolve.services.network.tracing.util import * from zepben.evolve.services.network.translator.network_proto2cim import * @@ -167,17 +164,47 @@ from zepben.evolve.services.network.tracing.connectivity.transformer_phase_paths import * from zepben.evolve.services.network.tracing.connectivity.xy_candidate_phase_paths import * from zepben.evolve.services.network.tracing.connectivity.xy_phase_step import * -from zepben.evolve.services.network.tracing.feeder.direction_status import * + from zepben.evolve.services.network.tracing.feeder.assign_to_feeders import * from zepben.evolve.services.network.tracing.feeder.assign_to_lv_feeders import * +from zepben.evolve.services.network.tracing.feeder.clear_direction import * +from zepben.evolve.services.network.tracing.feeder.direction_status import * +from zepben.evolve.services.network.tracing.feeder.feeder_direction import * +from zepben.evolve.services.network.tracing.feeder.set_direction import * + +from zepben.evolve.services.network.tracing.networktrace.actions.equipment_tree_builder import * +from zepben.evolve.services.network.tracing.networktrace.actions.tree_node import * +from zepben.evolve.services.network.tracing.networktrace.conditions.conditions import * +from zepben.evolve.services.network.tracing.networktrace.conditions.direction_condition import * +from zepben.evolve.services.network.tracing.networktrace.conditions.equipment_step_limit_condition import * +from zepben.evolve.services.network.tracing.networktrace.conditions.equipment_type_step_limit_condition import * +from zepben.evolve.services.network.tracing.networktrace.conditions.network_trace_stop_condition import * +from zepben.evolve.services.network.tracing.networktrace.conditions.network_trace_queue_condition import * +from zepben.evolve.services.network.tracing.networktrace.conditions.open_condition import * +from zepben.evolve.services.network.tracing.networktrace.operators.equipment_container_state_operators import * +from zepben.evolve.services.network.tracing.networktrace.operators.feeder_direction_state_operations import * +from zepben.evolve.services.network.tracing.networktrace.operators.in_service_state_operators import * +from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import * +from zepben.evolve.services.network.tracing.networktrace.operators.open_state_operators import * +from zepben.evolve.services.network.tracing.networktrace.operators.phase_state_operators import * +from zepben.evolve.services.network.tracing.networktrace.compute_data import * + from zepben.evolve.services.network.tracing.phases.phase_status import * from zepben.evolve.services.network.tracing.phases.phase_inferrer import * from zepben.evolve.services.network.tracing.phases.remove_phases import * -from zepben.evolve.services.network.tracing.find_swer_equipment import * -from zepben.evolve.services.network.tracing.traversal.queue_condition import * +from zepben.evolve.services.network.tracing.phases.set_phases import * + from zepben.evolve.services.network.tracing.traversal.context_value_computer import * -from zepben.evolve.services.network.tracing.traversal.step_action import StepAction -from zepben.evolve.services.network.tracing.feeder.set_direction import * +from zepben.evolve.services.network.tracing.traversal.queue import * +from zepben.evolve.services.network.tracing.traversal.queue_condition import * +from zepben.evolve.services.network.tracing.traversal.step_action import * +from zepben.evolve.services.network.tracing.traversal.step_context import * +from zepben.evolve.services.network.tracing.traversal.stop_condition import * +from zepben.evolve.services.network.tracing.traversal.traversal import * +from zepben.evolve.services.network.tracing.traversal.traversal_condition import * +from zepben.evolve.services.network.tracing.traversal.weighted_priority_queue import * + +from zepben.evolve.services.network.tracing.find_swer_equipment import * from zepben.evolve.services.common.meta.data_source import * from zepben.evolve.services.common.meta.metadata_collection import * @@ -426,6 +453,5 @@ from zepben.evolve.services.network.tracing.phases.set_phases import * from zepben.evolve.testing.test_network_builder import * -from zepben.evolve.testing.test_traversal import * # @formatter:on diff --git a/src/zepben/evolve/database/sqlite/network/network_cim_reader.py b/src/zepben/evolve/database/sqlite/network/network_cim_reader.py index e00bbe5c1..daccaa0e7 100644 --- a/src/zepben/evolve/database/sqlite/network/network_cim_reader.py +++ b/src/zepben/evolve/database/sqlite/network/network_cim_reader.py @@ -1,4 +1,4 @@ -# Copyright 2024 Zeppelin Bend Pty Ltd +# Copyright 2025 Zeppelin Bend Pty Ltd # 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/. diff --git a/src/zepben/evolve/database/sqlite/network/network_database_reader.py b/src/zepben/evolve/database/sqlite/network/network_database_reader.py index 726e01002..4f7d01446 100644 --- a/src/zepben/evolve/database/sqlite/network/network_database_reader.py +++ b/src/zepben/evolve/database/sqlite/network/network_database_reader.py @@ -78,26 +78,29 @@ async def _post_load(self) -> bool: status = await super()._post_load() self._logger.info("Applying feeder direction to network...") - await self.set_feeder_direction.run(self.service, NetworkStateOperators.NORMAL) - await self.set_feeder_direction.run(self.service, NetworkStateOperators.CURRENT) + await self.set_feeder_direction.run(self.service, network_state_operators=NetworkStateOperators.NORMAL) + await self.set_feeder_direction.run(self.service, network_state_operators=NetworkStateOperators.CURRENT) self._logger.info("Feeder direction applied to network.") self._logger.info("Applying phases to network...") - await self.set_phases.run(self.service, NetworkStateOperators.NORMAL) - await self.set_phases.run(self.service, NetworkStateOperators.CURRENT) + await self.set_phases.run(self.service, network_state_operators=NetworkStateOperators.NORMAL) + await self.set_phases.run(self.service, network_state_operators=NetworkStateOperators.CURRENT) if self.infer_phases: - await self.phase_inferrer.run(self.service, NetworkStateOperators.NORMAL) - await self.phase_inferrer.run(self.service, NetworkStateOperators.CURRENT) + self._log_inferred_phases( + await self.phase_inferrer.run(self.service, network_state_operators=NetworkStateOperators.NORMAL), + await self.phase_inferrer.run(self.service, network_state_operators=NetworkStateOperators.CURRENT) + ) + self._logger.info("Phasing applied to network.") self._logger.info("Assigning equipment to feeders...") - await self.assign_to_feeders.run(self.service, NetworkStateOperators.NORMAL) - await self.assign_to_feeders.run(self.service, NetworkStateOperators.CURRENT) + await self.assign_to_feeders.run(self.service, network_state_operators=NetworkStateOperators.NORMAL) + await self.assign_to_feeders.run(self.service, network_state_operators=NetworkStateOperators.CURRENT) self._logger.info("Equipment assigned to feeders.") self._logger.info("Assigning equipment to LV feeders...") - await self.assign_to_lv_feeders.run(self.service, NetworkStateOperators.NORMAL) - await self.assign_to_lv_feeders.run(self.service, NetworkStateOperators.CURRENT) + await self.assign_to_lv_feeders.run(self.service, network_state_operators=NetworkStateOperators.NORMAL) + await self.assign_to_lv_feeders.run(self.service, network_state_operators=NetworkStateOperators.CURRENT) self._logger.info("Equipment assigned to LV feeders.") self._logger.info("Validating that each equipment is assigned to a container...") @@ -110,16 +113,21 @@ async def _post_load(self) -> bool: return status - def _log_inferred_phases(self, normal_inferred_phases: List, current_inferred_phases: List): # FIXME: set list contents classes, this'll likely explode until then - # FIXME: im pretty sure this should be building a dict of lists, not just a simple KV store. if so, this logic is way too simple + def _log_inferred_phases(self, + normal_inferred_phases: List[PhaseInferrer.InferredPhase], + current_inferred_phases: List[PhaseInferrer.InferredPhase]): + inferred_phases = {item.conducting_equipment: item for item in normal_inferred_phases} for it in current_inferred_phases: - ce = it.conducting_equipment - inferred_phases[ce] = (inferred_phases[ce] if inferred_phases[ce].suspect else it) + if it.conducting_equipment in inferred_phases: + left = inferred_phases[it.conducting_equipment] + inferred_phases[it.conducting_equipment] = left if left.suspect else it + else: + inferred_phases[it.conducting_equipment] = it - for phase in inferred_phases: - self._logger.warning(f"*** Action Required *** {phase.description()}") + for phase in inferred_phases.values(): + self._logger.warning(f"*** Action Required *** {phase.description}") def _validate_equipment_containers(self): missing_containers = [it for it in self.service.objects(Equipment) if not it.containers] diff --git a/src/zepben/evolve/model/cim/iec61970/base/core/conducting_equipment.py b/src/zepben/evolve/model/cim/iec61970/base/core/conducting_equipment.py index 7e58e84eb..66cc722f1 100644 --- a/src/zepben/evolve/model/cim/iec61970/base/core/conducting_equipment.py +++ b/src/zepben/evolve/model/cim/iec61970/base/core/conducting_equipment.py @@ -6,7 +6,7 @@ from __future__ import annotations import sys -from typing import List, Optional, Generator, TYPE_CHECKING +from typing import List, Optional, Generator, TYPE_CHECKING, Union from zepben.evolve.model.cim.iec61970.base.core.base_voltage import BaseVoltage from zepben.evolve.model.cim.iec61970.base.core.equipment import Equipment @@ -80,12 +80,31 @@ def num_terminals(self): """ return len(self._terminals) + def get_terminal(self, identifier: Union[int, str]): + """ + Get the `Terminal` for this `ConductingEquipment` identified by `mrid` or `sequence_number` + + :param identifier: the mRID of the required `Terminal`, or the `sequence_number` of the terminal in relation + to this `ConductingEquipment` + :return: The `Terminal` with the specified `mrid` if it exists + + Raises `KeyError` if `mrid` wasn't present. + Raises `TypeError` if the identifier wasn't a recognised type + """ + if isinstance(identifier, int): + return self.get_terminal_by_sn(identifier) + elif isinstance(identifier, str): + return self.get_terminal_by_mrid(identifier) + raise TypeError(f'`identifier` parameter not a recognised type: {type(identifier)}') + def get_terminal_by_mrid(self, mrid: str) -> Terminal: """ Get the `Terminal` for this `ConductingEquipment` identified by `mrid` - `mrid` the mRID of the required `Terminal` - Returns The `Terminal` with the specified `mrid` if it exists + :param mrid: the mRID of the required `Terminal` + + :return: The `Terminal` with the specified `mrid` if it exists + Raises `KeyError` if `mrid` wasn't present. """ return get_by_mrid(self._terminals, mrid) @@ -94,8 +113,10 @@ def get_terminal_by_sn(self, sequence_number: int): """ Get the `Terminal` on this `ConductingEquipment` by its `sequence_number`. - `sequence_number` The `sequence_number` of the `Terminal` in relation to this `ConductingEquipment`. - Returns The `Terminal` on this `ConductingEquipment` with sequence number `sequence_number` + :param sequence_number: The `sequence_number` of the `Terminal` in relation to this `ConductingEquipment`. + + :return: The `Terminal` on this `ConductingEquipment` with sequence number `sequence_number` + Raises IndexError if no `Terminal` was found with sequence_number `sequence_number`. """ for term in self._terminals: diff --git a/src/zepben/evolve/model/cim/iec61970/base/core/equipment.py b/src/zepben/evolve/model/cim/iec61970/base/core/equipment.py index aca5a5f3d..6e3e436b8 100644 --- a/src/zepben/evolve/model/cim/iec61970/base/core/equipment.py +++ b/src/zepben/evolve/model/cim/iec61970/base/core/equipment.py @@ -9,7 +9,7 @@ from typing import Optional, Generator, List, TYPE_CHECKING, TypeVar, Type if TYPE_CHECKING: - from zepben.evolve import UsagePoint, EquipmentContainer, OperationalRestriction + from zepben.evolve import UsagePoint, EquipmentContainer, OperationalRestriction, NetworkStateOperators TEquipmentContainer = TypeVar("TEquipmentContainer", bound=EquipmentContainer) from zepben.evolve.model.cim.iec61970.base.core.equipment_container import Feeder, Site @@ -17,12 +17,10 @@ from zepben.evolve.model.cim.iec61970.base.core.substation import Substation from zepben.evolve.model.cim.iec61970.infiec61970.feeder.lv_feeder import LvFeeder from zepben.evolve.util import nlen, get_by_mrid, ngen, safe_remove -from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators __all__ = ['Equipment'] - class Equipment(PowerSystemResource): """ Abstract class, should only be used through subclasses. @@ -64,11 +62,11 @@ def sites(self) -> Generator[Site, None, None]: """ return ngen(_of_type(self._equipment_containers, Site)) - def feeders(self, network_state_operators: NetworkStateOperators) -> Generator[Feeder, None, None]: + def feeders(self, network_state_operators: Type[NetworkStateOperators]) -> Generator[Feeder, None, None]: """ The `Feeder` this equipment belongs too based on `NetworkStateOperators` """ - if network_state_operators == NetworkStateOperators.NORMAL: + if network_state_operators.NORMAL: return self.normal_feeders else: return self.current_feeders @@ -80,11 +78,11 @@ def normal_feeders(self) -> Generator[Feeder, None, None]: """ return ngen(_of_type(self._equipment_containers, Feeder)) - def lv_feeders(self, network_state_operators: NetworkStateOperators) -> Generator[LvFeeder, None, None]: + def lv_feeders(self, network_state_operators: Type[NetworkStateOperators]) -> Generator[LvFeeder, None, None]: """ The `LvFeeder` this equipment belongs too based on `NetworkStateOperators` """ - if network_state_operators == NetworkStateOperators.NORMAL: + if network_state_operators.NORMAL: return self.normal_lv_feeders else: return self.current_lv_feeders diff --git a/src/zepben/evolve/model/cim/iec61970/base/core/equipment_container.py b/src/zepben/evolve/model/cim/iec61970/base/core/equipment_container.py index f0a6d8b0e..12e5d366b 100644 --- a/src/zepben/evolve/model/cim/iec61970/base/core/equipment_container.py +++ b/src/zepben/evolve/model/cim/iec61970/base/core/equipment_container.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import Optional, Dict, Generator, List, TYPE_CHECKING, TypeVar, Iterable +from typing import Optional, Dict, Generator, List, TYPE_CHECKING, TypeVar, Iterable, Type if TYPE_CHECKING: from zepben.evolve import Equipment, Terminal, Substation, LvFeeder, NetworkStateOperators @@ -446,7 +446,7 @@ class Site(EquipmentContainer): Note this is not a CIM concept - however represents an `EquipmentContainer` in CIM. This is to avoid the use of `EquipmentContainer` as a concrete class. """ - def find_lv_feeders(self, lv_feeder_start_points: Iterable[ConductingEquipment], state_operators: NetworkStateOperators) -> Generator[LvFeeder, None, None]: + def find_lv_feeders(self, lv_feeder_start_points: Iterable[ConductingEquipment], state_operators: Type[NetworkStateOperators]) -> Generator[LvFeeder, None, None]: from zepben.evolve.model.cim.iec61970.base.core.conducting_equipment import ConductingEquipment for ce in state_operators.get_equipment(self): if isinstance(ce, ConductingEquipment): diff --git a/src/zepben/evolve/model/cim/iec61970/base/core/terminal.py b/src/zepben/evolve/model/cim/iec61970/base/core/terminal.py index b903a7091..8063dfdcc 100644 --- a/src/zepben/evolve/model/cim/iec61970/base/core/terminal.py +++ b/src/zepben/evolve/model/cim/iec61970/base/core/terminal.py @@ -174,6 +174,6 @@ def is_feeder_head_terminal(self): def has_connected_busbars(self): try: - return any(it != self and it.conducting_equipment is BusbarSection for it in self.connectivity_node.terminals) == True + return any(it != self and isinstance(it.conducting_equipment, BusbarSection) for it in self.connectivity_node.terminals) == True except AttributeError: return False diff --git a/src/zepben/evolve/model/cim/iec61970/base/wires/aclinesegment.py b/src/zepben/evolve/model/cim/iec61970/base/wires/aclinesegment.py index dca7c7ec5..bf3677d31 100644 --- a/src/zepben/evolve/model/cim/iec61970/base/wires/aclinesegment.py +++ b/src/zepben/evolve/model/cim/iec61970/base/wires/aclinesegment.py @@ -64,6 +64,7 @@ class AcLineSegment(Conductor): However, boundary lines may have slightly different BaseVoltage.nominalVoltages and variation is allowed. Larger voltage difference in general requires use of an equivalent branch. """ + max_terminals = 2 per_length_impedance: Optional[PerLengthImpedance] = None """A `zepben.evolve.PerLengthImpedance` describing this AcLineSegment""" diff --git a/src/zepben/evolve/model/cim/iec61970/base/wires/clamp.py b/src/zepben/evolve/model/cim/iec61970/base/wires/clamp.py index 92d6597a1..8d0a0c915 100644 --- a/src/zepben/evolve/model/cim/iec61970/base/wires/clamp.py +++ b/src/zepben/evolve/model/cim/iec61970/base/wires/clamp.py @@ -25,3 +25,9 @@ class Clamp(ConductingEquipment): ac_line_segment: Optional[AcLineSegment] = None """The line segment to which the clamp is connected.""" + + max_terminals = 1 + + @property + def length_from_T1_or_0(self) -> float: + return self.length_from_terminal_1 or 0.0 diff --git a/src/zepben/evolve/model/cim/iec61970/base/wires/cut.py b/src/zepben/evolve/model/cim/iec61970/base/wires/cut.py index 5ee09bc75..149d98bb2 100644 --- a/src/zepben/evolve/model/cim/iec61970/base/wires/cut.py +++ b/src/zepben/evolve/model/cim/iec61970/base/wires/cut.py @@ -24,8 +24,14 @@ class Cut(Switch): be connected at them. """ + max_terminals = 2 + length_from_terminal_1: Optional[float] = None """The length to the place where the cut is located starting from side one of the cut line segment, i.e. the line segment Terminal with sequenceNumber equal to 1.""" ac_line_segment: Optional[AcLineSegment] = None """The line segment to which the cut is applied.""" + + @property + def length_from_T1_or_0(self) -> float: + return self.length_from_terminal_1 or 0.0 diff --git a/src/zepben/evolve/services/network/network_service.py b/src/zepben/evolve/services/network/network_service.py index abaaf38ab..69086adf9 100644 --- a/src/zepben/evolve/services/network/network_service.py +++ b/src/zepben/evolve/services/network/network_service.py @@ -11,13 +11,12 @@ import logging from enum import Enum from pathlib import Path -from typing import TYPE_CHECKING, Dict, List, Union, Iterable, Optional, Generator, Set - -from zepben.evolve.util import ngen +from typing import TYPE_CHECKING, Dict, List, Union, Iterable, Optional, Set from zepben.evolve.model.cim.iec61970.base.auxiliaryequipment.auxiliary_equipment import AuxiliaryEquipment from zepben.evolve.model.cim.iec61970.infiec61970.feeder.lv_feeder import LvFeeder from zepben.evolve.model.cim.iec61970.base.core.equipment_container import Feeder +from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal from zepben.evolve.model.cim.iec61970.base.core.connectivity_node import ConnectivityNode from zepben.evolve.model.cim.iec61970.base.core.phase_code import PhaseCode @@ -26,7 +25,7 @@ from zepben.evolve.services.network.tracing.connectivity.terminal_connectivity_connected import TerminalConnectivityConnected if TYPE_CHECKING: - from zepben.evolve import Terminal, SinglePhaseKind, ConnectivityResult, Measurement, ConductingEquipment + from zepben.evolve import SinglePhaseKind, ConnectivityResult, Measurement, ConductingEquipment logger = logging.getLogger(__name__) TRACED_NETWORK_FILE = str(Path.home().joinpath(Path("traced.json"))) @@ -153,13 +152,26 @@ def remove_measurement(self, measurement) -> bool: self._remove_measurement_index(measurement) return self.remove(measurement) + def connect(self, terminal: Terminal, to: Union[str, Terminal]) -> bool: + """ + Connect a `Terminal` to either a `Terminal` or `ConnectivityNode` depending on the type of `to` + + :return: the boolean result of the action + """ + if isinstance(to, Terminal): + return self.connect_terminals(terminal, to) + elif isinstance(to, str): + return self.connect_by_mrid(terminal, to) + else: + raise TypeError(f'to parameter not a recognised type: {type(to)}') + def connect_by_mrid(self, terminal: Terminal, connectivity_node_mrid: str) -> bool: """ Connect a `Terminal` to the `ConnectivityNode` with mRID `connectivity_node_mrid` `terminal` The `Terminal` to connect. `connectivity_node_mrid` The mRID of the `ConnectivityNode`. Will be created in the `Network` if it doesn't already exist. - Returns True if the connection was made or already existed, False if `Terminal` was already connected to a + :return: `True` if the connection was made or already existed, `False` if `Terminal` was already connected to a different `ConnectivityNode` """ if not connectivity_node_mrid: @@ -175,7 +187,7 @@ def connect_by_mrid(self, terminal: Terminal, connectivity_node_mrid: str) -> bo def connect_terminals(self, terminal1: Terminal, terminal2: Terminal) -> bool: """ Connect two `Terminal`s - Returns True if the `Terminal`s could be connected, False otherwise. + :return: `True` if the `Terminal` could be connected, `False` otherwise. """ status = _attempt_to_reuse_connection(terminal1, terminal2) if status == ProcessStatus.PROCESSED: @@ -200,7 +212,7 @@ def disconnect(self, terminal: Terminal): """ Disconnect a `Terminal`` from its `ConnectivityNode`. Will also remove the `ConnectivityNode` from this `Network` if it no longer has any terminals. - `terminal` The `Terminal` to disconnect. + :param terminal: The `Terminal` to disconnect. """ cn = terminal.connectivity_node if cn is None: @@ -214,7 +226,8 @@ def disconnect_by_mrid(self, connectivity_node_mrid: str): """ Disconnect a `ConnectivityNode` from this `Network`. Will disconnect all ``Terminal`s from the `ConnectivityNode` - `connectivity_node_mrid` The mRID of the `ConnectivityNode` to disconnect. + :param connectivity_node_mrid: The mRID of the `ConnectivityNode` to disconnect. + Raises `KeyError` if there is no `ConnectivityNode` for `connectivity_node_mrid` """ cn = self._connectivity_nodes[connectivity_node_mrid] diff --git a/src/zepben/evolve/services/network/tracing/busbranch_trace.py b/src/zepben/evolve/services/network/tracing/busbranch_trace.py index 0468ea944..a3b2a7d63 100644 --- a/src/zepben/evolve/services/network/tracing/busbranch_trace.py +++ b/src/zepben/evolve/services/network/tracing/busbranch_trace.py @@ -25,7 +25,7 @@ def __init__(self, queue_next: Traversal.QueueNext): ) super().__init__(queue_type) - def on_reset(self): + def on_reset(self) -> None: self._tracker.clear() def can_visit_item(self, item: BusBranchTraceStep, context: StepContext) -> bool: diff --git a/src/zepben/evolve/services/network/tracing/connectivity/terminal_connectivity_connected.py b/src/zepben/evolve/services/network/tracing/connectivity/terminal_connectivity_connected.py index cb055a059..f421512de 100644 --- a/src/zepben/evolve/services/network/tracing/connectivity/terminal_connectivity_connected.py +++ b/src/zepben/evolve/services/network/tracing/connectivity/terminal_connectivity_connected.py @@ -2,7 +2,7 @@ # 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/. -from queue import Queue + from typing import List, Iterable, Optional, Set, Dict, Callable from zepben.evolve.services.network.tracing.connectivity.connectivity_result import ConnectivityResult @@ -10,11 +10,13 @@ from zepben.evolve.services.network.tracing.connectivity.xy_phase_step import XyPhaseStep from zepben.evolve.services.network.tracing.connectivity.phase_paths import viable_inferred_phase_connectivity, straight_phase_connectivity -from zepben.evolve import Terminal, PhaseCode, SinglePhaseKind, Switch, LifoQueue +from zepben.evolve import Terminal, PhaseCode, SinglePhaseKind, Switch from zepben.evolve.services.network.tracing.connectivity.nominal_phase_path import NominalPhasePath __all__ = ["TerminalConnectivityConnected"] +from zepben.evolve.services.network.tracing.traversal.queue import LIFODeque + class TerminalConnectivityConnected: """ @@ -123,7 +125,7 @@ def _add_xy_phase_paths(self, terminal: Terminal, add_path: Callable[[SinglePhas add_path(from_phase, to_phase) def _find_xy_candidate_phases(self, xy_phases: Dict[Terminal, PhaseCode], primary_phases: Dict[Terminal, PhaseCode]) -> XyCandidatePhasePaths: - queue = LifoQueue[XyPhaseStep]() + queue = LIFODeque[XyPhaseStep]() visited = set() candidate_phases = self._create_candidate_phases() @@ -135,7 +137,7 @@ def _find_xy_candidate_phases(self, xy_phases: Dict[Terminal, PhaseCode], primar # noinspection PyArgumentList self._find_more_xy_candidate_phases(XyPhaseStep(terminal, xy_phase_code), visited, queue, candidate_phases) - while not queue.empty(): + while len(queue) > 0: self._find_more_xy_candidate_phases(queue.pop(), visited, queue, candidate_phases) return candidate_phases @@ -144,7 +146,7 @@ def _find_more_xy_candidate_phases( self, step: XyPhaseStep, visited: Set[XyPhaseStep], - queue: Queue[XyPhaseStep], + queue: LIFODeque[XyPhaseStep], candidate_phases: XyCandidatePhasePaths ): if step in visited: @@ -176,7 +178,7 @@ def _check_traced_phases(step: XyPhaseStep, candidate_phases: XyCandidatePhasePa return found_traced @staticmethod - def _queue_next(terminal: Terminal, phase_code: PhaseCode, queue: Queue[XyPhaseStep]): + def _queue_next(terminal: Terminal, phase_code: PhaseCode, queue: LIFODeque[XyPhaseStep]): ce = terminal.conducting_equipment if not ce: return @@ -187,7 +189,7 @@ def _queue_next(terminal: Terminal, phase_code: PhaseCode, queue: Queue[XyPhaseS for connected in other.connectivity_node.terminals: if connected.conducting_equipment != ce: # noinspection PyArgumentList - queue.put(XyPhaseStep(connected, phase_code)) + queue.append(XyPhaseStep(connected, phase_code)) def _find_xy_phases(terminal: Terminal): diff --git a/src/zepben/evolve/services/network/tracing/feeder/assign_to_feeders.py b/src/zepben/evolve/services/network/tracing/feeder/assign_to_feeders.py index 63a45c35d..c8fae1a85 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/assign_to_feeders.py +++ b/src/zepben/evolve/services/network/tracing/feeder/assign_to_feeders.py @@ -3,9 +3,9 @@ # 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/. from collections.abc import Collection -from typing import Iterable, Generator, Union, List, Dict, Any, Set +from typing import Iterable, Union, List, Dict, Any, Set, Type, Generator -from zepben.evolve import Switch, AuxiliaryEquipment, ProtectedSwitch, Equipment, LvFeeder +from zepben.evolve import Switch, AuxiliaryEquipment, ProtectedSwitch, Equipment, LvFeeder, PowerElectronicsConnection from zepben.evolve.model.cim.iec61970.base.core.conducting_equipment import ConductingEquipment from zepben.evolve.model.cim.iec61970.base.core.equipment_container import Feeder, EquipmentContainer from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal @@ -18,6 +18,7 @@ from zepben.evolve.services.network.tracing.networktrace.network_trace_action_type import NetworkTraceActionType from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing +from zepben.evolve.services.network.tracing.networktrace.conditions.conditions import stop_at_open from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators from zepben.evolve.services.network.tracing.traversal.step_context import StepContext @@ -32,7 +33,7 @@ class AssignToFeeders: @staticmethod async def run(network: NetworkService, - network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL, + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, start_terminal: Terminal=None): """ Assign equipment to feeders in the specified network, given an optional start terminal. @@ -47,10 +48,10 @@ async def run(network: NetworkService, class BaseFeedersInternal: - def __init__(self, network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL): + def __init__(self, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): self.network_state_operators = network_state_operators - def _feeders_from_terminal(self, terminal: Terminal): + def _feeders_from_terminal(self, terminal: Terminal) -> Generator[Feeder, None, None]: return terminal.conducting_equipment.feeders(self.network_state_operators) def _associate_equipment_with_containers(self, equipment_containers: Iterable[EquipmentContainer], equipment: Iterable[Equipment]): @@ -65,7 +66,10 @@ def _associate_relay_systems_with_containers(self, equipment_containers: Iterabl for relayFunction in to_equipment.relay_functions for scheme in relayFunction.schemes if scheme.system is not None] - ) + ) + + def _associate_power_electronic_units(self, equipment_containers: Iterable[EquipmentContainer], to_equipment: PowerElectronicsConnection): + self._associate_equipment_with_containers(equipment_containers, to_equipment.units) def _feeder_energizes(self, feeders: Iterable[Union[LvFeeder, Feeder]], lv_feeders: Iterable[LvFeeder]): for feeder in feeders: @@ -73,14 +77,13 @@ def _feeder_energizes(self, feeders: Iterable[Union[LvFeeder, Feeder]], lv_feede self.network_state_operators.associate_energizing_feeder(feeder, lv_feeder) def _feeder_try_energize_lv_feeders(self, feeders: Iterable[Feeder], lv_feeder_start_points: Set[ConductingEquipment], to_equipment: PowerTransformer): - sites = [] - for eq in to_equipment: - sites.extend(eq.sites) + sites = list(to_equipment.sites) + lv_feeders = [] if len(sites) > 0: - lv_feeders = [s.find_lv_feeders(lv_feeder_start_points, self.network_state_operators) for s in sites] + for s in sites: + lv_feeders.extend(lv_f for lv_f in s.find_lv_feeders(lv_feeder_start_points, self.network_state_operators)) else: - lv_feeders = [] for eq in to_equipment: lv_feeders.extend(eq.lv_feeders(self.network_state_operators)) @@ -110,7 +113,7 @@ async def run(self, feeder_start_points, lv_feeder_start_points, terminal_to_aux_equipment, - self._feeders_from_terminal(start_terminal)) + list(self._feeders_from_terminal(start_terminal))) async def run_with_feeders(self, terminal: Terminal, @@ -148,7 +151,7 @@ async def step_action(nts: NetworkTraceStep, context: StepContext): return ( Tracing.network_trace(self.network_state_operators, NetworkTraceActionType.ALL_STEPS) - .add_condition(self.network_state_operators.stop_at_open()) + .add_condition(stop_at_open()) .add_stop_condition(lambda step, ctx: step.path.to_equipment in feeder_start_points) .add_queue_condition(lambda step, ctx, _, __: not _reached_substation_transformer(step.path.to_equipment)) .add_queue_condition(lambda step, ctx, _, __: not _reached_lv(step.path.to_equipment)) @@ -165,13 +168,15 @@ async def _process(self, if step_path.traced_internally and not step_context.is_start_item: return - for equip_group in (terminal_to_aux_equipment.get(step_path.to_terminal, {}), [step_path.to_equipment]): - self._associate_equipment_with_containers(feeders_to_assign, equip_group) + self._associate_equipment_with_containers(feeders_to_assign, terminal_to_aux_equipment.get(step_path.to_terminal, {})) + self._associate_equipment_with_containers(feeders_to_assign, [step_path.to_equipment]) if isinstance(step_path.to_equipment, PowerTransformer): self._feeder_try_energize_lv_feeders(feeders_to_assign, lv_feeder_start_points, step_path.to_equipment) elif isinstance(step_path.to_equipment, ProtectedSwitch): self._associate_relay_systems_with_containers(feeders_to_assign, step_path.to_equipment) + elif isinstance(step_path.to_equipment, PowerElectronicsConnection): + self._associate_power_electronic_units(feeders_to_assign, step_path.to_equipment) diff --git a/src/zepben/evolve/services/network/tracing/feeder/assign_to_lv_feeders.py b/src/zepben/evolve/services/network/tracing/feeder/assign_to_lv_feeders.py index 5c6ac8099..21622bbad 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/assign_to_lv_feeders.py +++ b/src/zepben/evolve/services/network/tracing/feeder/assign_to_lv_feeders.py @@ -2,10 +2,10 @@ # 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/. +from functools import singledispatchmethod +from typing import Collection, List, Generator, TypeVar, Dict, Set, Type -from typing import Collection, List, Generator, TypeVar, Dict, Set - -from zepben.evolve import Switch, AuxiliaryEquipment, ProtectedSwitch +from zepben.evolve import Switch, AuxiliaryEquipment, ProtectedSwitch, PowerElectronicsConnection from zepben.evolve.model.cim.iec61970.base.core.conducting_equipment import ConductingEquipment from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal from zepben.evolve.model.cim.iec61970.infiec61970.feeder.lv_feeder import LvFeeder @@ -15,6 +15,7 @@ from zepben.evolve.services.network.tracing.networktrace.network_trace_action_type import NetworkTraceActionType from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators +from zepben.evolve.services.network.tracing.networktrace.conditions.conditions import stop_at_open from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing from zepben.evolve.services.network.tracing.traversal.step_context import StepContext @@ -24,12 +25,22 @@ class AssignToLvFeeders: + @singledispatchmethod @staticmethod async def run(network: NetworkService, - network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL, + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, start_terminal: Terminal=None): await AssignToLvFeedersInternal(network_state_operators).run(network, start_terminal) + @run.register + @staticmethod + async def _(terminal: Terminal, + lv_feeder_start_points: Set[ConductingEquipment], + terminal_to_aux_equipment: Dict[Terminal, List[AuxiliaryEquipment]], + lv_feeders_to_assign: List[LvFeeder], + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL + ): + await AssignToLvFeedersInternal(network_state_operators).run_with_feeders(terminal, lv_feeder_start_points, terminal_to_aux_equipment, lv_feeders_to_assign) class AssignToLvFeedersInternal(BaseFeedersInternal): """ @@ -112,7 +123,7 @@ async def step_action(nts: NetworkTraceStep, context): action_step_type=NetworkTraceActionType.ALL_STEPS, compute_data=(lambda _, __, next_path: next_path.to_equipment in lv_feeder_start_points) ) - .add_condition(self.network_state_operators.stop_at_open()) + .add_condition(stop_at_open()) .add_stop_condition(lambda step, ctx: step.data) .add_queue_condition(queue_condition) .add_step_action(step_action) @@ -129,23 +140,27 @@ async def _process(self, if step_path.traced_internally and not step_context.is_start_item: return + # It might be tempting to check `stepContext.isStopping`, but that would also pick up open points between LV feeders which is not good. if found_lv_feeder: found_lv_feeders = list(self._find_lv_feeders(step_path.to_equipment, lv_feeder_start_points)) - for energizing_feeder in (self.network_state_operators.get_energizing_feeders(it) for it in found_lv_feeders): - for feeder_group in (lv_feeders_to_assign, found_lv_feeders): - self._feeder_energizes(feeder_group, energizing_feeder) + for it in found_lv_feeders: + # Energize the LV feeders that we are processing by the energizing feeders of what we found + self._feeder_energizes(self.network_state_operators.get_energizing_feeders(it), lv_feeders_to_assign) + + for it in lv_feeders_to_assign: + # Energize the LV feeders we found by the energizing feeders we are processing + self._feeder_energizes(self.network_state_operators.get_energizing_feeders(it), found_lv_feeders) - try: - aux_equip_for_this_terminal = terminal_to_aux_equipment[step_path.to_terminal] - except KeyError: - aux_equip_for_this_terminal = [] + aux_equip_for_this_terminal = terminal_to_aux_equipment.get(step_path.to_terminal, {}) - for equip_group in (aux_equip_for_this_terminal, [step_path.to_equipment]): - self._associate_equipment_with_containers(lv_feeders_to_assign, equip_group) + self._associate_equipment_with_containers(lv_feeders_to_assign, [step_path.to_equipment]) + self._associate_equipment_with_containers(lv_feeders_to_assign, aux_equip_for_this_terminal) if isinstance(step_path.to_equipment, ProtectedSwitch): self._associate_relay_systems_with_containers(lv_feeders_to_assign, step_path.to_equipment) + elif isinstance(step_path.to_equipment, PowerElectronicsConnection): + self._associate_power_electronic_units(lv_feeders_to_assign, step_path.to_equipment) def _find_lv_feeders(self, ce: ConductingEquipment, lv_feeder_start_points: Set[ConductingEquipment]) -> Generator[LvFeeder, None, None]: sites = list(ce.sites) diff --git a/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py b/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py index 4cb58b93e..4bcde67b8 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py +++ b/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py @@ -4,11 +4,11 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Type from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal -from zepben.evolve import FeederDirection, Traversal +from zepben.evolve.services.network.tracing.feeder.feeder_direction import FeederDirection from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing from zepben.evolve.services.network.tracing.traversal.weighted_priority_queue import WeightedPriorityQueue from zepben.evolve.services.network.tracing.networktrace.network_trace import NetworkTrace @@ -18,6 +18,8 @@ if TYPE_CHECKING: from zepben.evolve import StepContext, NetworkTraceStep +__all__ = ['ClearDirection'] + class ClearDirection: @@ -28,7 +30,7 @@ class ClearDirection: # async def run(self, terminal: Terminal, - network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL ) -> list[Terminal]: """ Clears the feeder direction from a terminal and the connected equipment chain. @@ -47,13 +49,13 @@ async def run(self, return feeder_head_terminals @staticmethod - def _create_trace(state_operators: NetworkStateOperators, + def _create_trace(state_operators: Type[NetworkStateOperators], visited_feeder_head_terminals: list[Terminal] ) -> NetworkTrace[Any]: def queue_condition(step: NetworkTraceStep, context: StepContext, _, __): return state_operators.get_direction(step.path.to_terminal) != FeederDirection.NONE - def step_action(item, context): + def step_action(item: NetworkTraceStep, context: StepContext): state_operators.set_direction(item.path.to_terminal, FeederDirection.NONE) visited_feeder_head_terminals.append(item.path.to_terminal) if item.path.to_terminal.is_feeder_head_terminal() else None @@ -67,4 +69,4 @@ def step_action(item, context): .add_condition(state_operators.stop_at_open()) .add_queue_condition(queue_condition) .add_step_action(step_action) - ) \ No newline at end of file + ) diff --git a/src/zepben/evolve/services/network/tracing/feeder/set_direction.py b/src/zepben/evolve/services/network/tracing/feeder/set_direction.py index 894bcf4b1..6746c7d41 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/set_direction.py +++ b/src/zepben/evolve/services/network/tracing/feeder/set_direction.py @@ -3,20 +3,23 @@ # 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/. from __future__ import annotations -from typing import Optional, TYPE_CHECKING + +from functools import singledispatchmethod +from typing import Optional, TYPE_CHECKING, Type from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal +from zepben.evolve.model.cim.iec61970.base.core.equipment_container import Feeder from zepben.evolve.model.cim.iec61970.base.wires.connectors import BusbarSection from zepben.evolve.model.cim.iec61970.base.wires.power_transformer import PowerTransformer - -from zepben.evolve import Feeder, Traversal -from zepben.evolve.services.network.tracing.networktrace.compute_data import ComputeData +from zepben.evolve.model.cim.iec61970.base.wires.cut import Cut +from zepben.evolve.services.network.tracing.networktrace.conditions.conditions import stop_at_open from zepben.evolve.services.network.tracing.networktrace.network_trace_action_type import NetworkTraceActionType from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing from zepben.evolve.services.network.tracing.feeder.feeder_direction import FeederDirection from zepben.evolve.services.network.tracing.networktrace.network_trace import NetworkTrace from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep +from zepben.evolve.services.network.tracing.traversal.weighted_priority_queue import WeightedPriorityQueue if TYPE_CHECKING: @@ -33,20 +36,26 @@ class SetDirection: @staticmethod def _compute_data(reprocessed_loop_terminals: list[Terminal], - state_operators: NetworkStateOperators, + state_operators: Type[NetworkStateOperators], step: NetworkTraceStep[FeederDirection], next_path: NetworkTraceStep.Path) -> FeederDirection: if next_path.to_equipment is BusbarSection: return FeederDirection.CONNECTOR - direction_applied = step.data + def next_direction_func(): + if step.data == FeederDirection.NONE: + return FeederDirection.NONE + elif next_path.traced_internally: + return FeederDirection.DOWNSTREAM + elif isinstance(next_path.to_equipment, Cut): + return FeederDirection.UPSTREAM + elif next_path.did_traverse_ac_line_segment: + return FeederDirection.DOWNSTREAM + else: + return FeederDirection.UPSTREAM - next_direction = FeederDirection.NONE - if direction_applied == FeederDirection.UPSTREAM: - next_direction = FeederDirection.DOWNSTREAM - elif direction_applied in (FeederDirection.DOWNSTREAM, FeederDirection.CONNECTOR): - next_direction = FeederDirection.UPSTREAM + next_direction = next_direction_func() # # NOTE: Stopping / short-circuiting by checking that the next direction is already present in the toTerminal, @@ -68,7 +77,7 @@ def _compute_data(reprocessed_loop_terminals: list[Terminal], return next_direction return FeederDirection.NONE - async def _create_traversal(self, state_operators: NetworkStateOperators) -> NetworkTrace[FeederDirection]: + async def _create_traversal(self, state_operators: Type[NetworkStateOperators]) -> NetworkTrace[FeederDirection]: reprocessed_loop_terminals: list[Terminal] = [] def queue_condition(nts: NetworkTraceStep, *args): @@ -85,9 +94,11 @@ def stop_condition(nts: NetworkTraceStep, *args): Tracing.network_trace_branching( network_state_operators=state_operators, action_step_type=NetworkTraceActionType.ALL_STEPS, + queue_factory=lambda: WeightedPriorityQueue.process_queue(lambda it: it.path.to_terminal.phases.num_phases), + branch_queue_factory=lambda: WeightedPriorityQueue.branch_queue(lambda it: it.path.to_terminal.phases.num_phases), compute_data=lambda step, _, next_path: self._compute_data(reprocessed_loop_terminals, state_operators, step, next_path) ) - .add_condition(state_operators.stop_at_open()) + .add_condition(stop_at_open()) .add_stop_condition(stop_condition) .add_queue_condition(queue_condition) .add_step_action(step_action) @@ -105,7 +116,8 @@ def _reached_substation_transformer(terminal: Terminal) -> bool: def _is_normally_open_switch(conducting_equipment: Optional[ConductingEquipment]): return isinstance(conducting_equipment, Switch) and conducting_equipment.is_normally_open() - async def run(self, network: NetworkService, network_state_operators: NetworkStateOperators): + @singledispatchmethod + async def run(self, network: NetworkService, network_state_operators: Type[NetworkStateOperators]): """ Apply feeder directions from all feeder head terminals in the network. @@ -119,7 +131,8 @@ async def run(self, network: NetworkService, network_state_operators: NetworkSta if not network_state_operators.is_open(head_terminal, None): await self.run_terminal(terminal, network_state_operators) - async def run_terminal(self, terminal: Terminal, network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL): + @run.register + async def run_terminal(self, terminal: Terminal, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): """ Apply [FeederDirection.DOWNSTREAM] from the [terminal]. diff --git a/src/zepben/evolve/services/network/tracing/networktrace/actions/equipment_tree_builder.py b/src/zepben/evolve/services/network/tracing/networktrace/actions/equipment_tree_builder.py index 14b7f6d65..ea65d4026 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/actions/equipment_tree_builder.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/actions/equipment_tree_builder.py @@ -4,7 +4,7 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. import uuid -from typing import Any +from typing import Any, Generator from zepben.evolve.model.cim.iec61970.base.core.conducting_equipment import ConductingEquipment from zepben.evolve.services.network.tracing.networktrace.actions.tree_node import TreeNode @@ -14,6 +14,8 @@ EquipmentTreeNode = TreeNode[ConductingEquipment] +__all__ = ['EquipmentTreeBuilder'] + class EquipmentTreeBuilder(StepActionWithContextValue): """ @@ -38,8 +40,8 @@ def __init__(self): self.key = str(uuid.uuid4()) @property - def roots(self): - return self._roots.values() + def roots(self) -> Generator[TreeNode[ConductingEquipment], None, None]: + return (r for r in self._roots.values()) def compute_initial_value(self, item: NetworkTraceStep[Any]) -> EquipmentTreeNode: node = self._roots.get(item.path.to_equipment) diff --git a/src/zepben/evolve/services/network/tracing/networktrace/actions/tree_node.py b/src/zepben/evolve/services/network/tracing/networktrace/actions/tree_node.py index 140474c0f..47720b079 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/actions/tree_node.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/actions/tree_node.py @@ -9,6 +9,8 @@ T = TypeVar('T') +__all__ = ['TreeNode'] + class TreeNode(Generic[T]): """ diff --git a/src/zepben/evolve/services/network/tracing/networktrace/compute_data.py b/src/zepben/evolve/services/network/tracing/networktrace/compute_data.py index 505a6e304..f345c09d6 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/compute_data.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/compute_data.py @@ -2,13 +2,15 @@ # 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/. -from typing import TypeVar, Generic, Any +from typing import TypeVar, Generic from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep from zepben.evolve.services.network.tracing.traversal.step_context import StepContext T = TypeVar('T') +__all__ = ['ComputeData', 'ComputeDataWithPaths'] + class ComputeData(Generic[T]): """ @@ -38,7 +40,7 @@ class ComputeDataWithPaths(Generic[T]): def __init__(self, func): self._func = func or (lambda *args: None) - def compute_next(self, current_step: NetworkTraceStep[T], current_context: StepContext, next_path: NetworkTraceStep.Path, next_paths: list[NetworkTraceStep.Path, Any]) -> T: + def compute_next(self, current_step: NetworkTraceStep[T], current_context: StepContext, next_path: NetworkTraceStep.Path, next_paths: list[NetworkTraceStep.Path]) -> T: """ Called for each new NetworkTraceStep in a NetworkTrace. The value returned from this function will be stored against the next step within NetworkTraceStep. data. diff --git a/src/zepben/evolve/services/network/tracing/networktrace/conditions/__init__.py b/src/zepben/evolve/services/network/tracing/networktrace/conditions/__init__.py new file mode 100644 index 000000000..e7d95cd55 --- /dev/null +++ b/src/zepben/evolve/services/network/tracing/networktrace/conditions/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Zeppelin Bend Pty Ltd +# 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/. diff --git a/src/zepben/evolve/services/network/tracing/networktrace/conditions/conditions.py b/src/zepben/evolve/services/network/tracing/networktrace/conditions/conditions.py new file mode 100644 index 000000000..432c4c2c2 --- /dev/null +++ b/src/zepben/evolve/services/network/tracing/networktrace/conditions/conditions.py @@ -0,0 +1,70 @@ +# Copyright 2025 Zeppelin Bend Pty Ltd +# 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/. + +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar, Type, Callable + +from zepben.evolve.services.network.tracing.feeder.feeder_direction import FeederDirection +from zepben.evolve.services.network.tracing.networktrace.conditions.direction_condition import DirectionCondition +from zepben.evolve.services.network.tracing.networktrace.conditions.equipment_step_limit_condition import EquipmentStepLimitCondition +from zepben.evolve.services.network.tracing.networktrace.conditions.equipment_type_step_limit_condition import EquipmentTypeStepLimitCondition + +T = TypeVar('T') + +if TYPE_CHECKING: + from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep + from zepben.evolve.services.network.tracing.traversal.queue_condition import QueueCondition + from zepben.evolve.services.network.tracing.traversal.stop_condition import StopCondition + from zepben.evolve import ConductingEquipment, NetworkStateOperators + + DSLLambda = Callable[[NetworkStateOperators], QueueCondition[NetworkTraceStep[T]]] + +__all__ = ['upstream', 'downstream', 'with_direction', 'limit_equipment_steps', 'stop_at_open'] + + +def upstream() -> DSLLambda: + """ + Creates a [NetworkTrace] condition that will cause tracing a feeder upstream (towards the head terminal). + This uses [FeederDirectionStateOperations.get_direction] receiver instance method within the condition. + + :return: [NetworkTraceQueueCondition] that results in upstream tracing. + """ + return lambda state_operator: state_operator.with_direction(FeederDirection.UPSTREAM) + +def downstream() -> DSLLambda: + """ + Creates a [NetworkTrace] condition that will cause tracing a feeder downstream (away from the head terminal). + This uses [FeederDirectionStateOperations.get_direction] receiver instance method within the condition. + + :return: [NetworkTraceQueueCondition] that results in downstream tracing. + """ + return lambda state_operator: state_operator.with_direction(FeederDirection.DOWNSTREAM) + +def with_direction(direction: FeederDirection) -> DSLLambda: + """ + Creates a [NetworkTrace] condition that will cause tracing only terminals with directions that match [direction]. + This uses [FeederDirectionStateOperations.get_direction] receiver instance method within the condition. + + :return: [NetworkTraceQueueCondition] that results in upstream tracing. + """ + return lambda state_operator: DirectionCondition(direction, state_operator) + +def limit_equipment_steps(limit: int, equipment_type: Type[ConductingEquipment]=None) -> StopCondition[NetworkTraceStep[T]]: + """ + Creates a [NetworkTrace] condition that stops tracing a path once a specified number of equipment steps have been reached. + + :param limit: The maximum number of equipment steps allowed before stopping. + :param equipment_type: The class of the equipment type to track against the limit + + :return: A [NetworkTraceStopCondition] that stops tracing the path once the step limit is reached. + """ + if equipment_type is not None: + return EquipmentTypeStepLimitCondition(limit, equipment_type) + return EquipmentStepLimitCondition(limit) + +def stop_at_open(): + return lambda state_operator: state_operator.stop_at_open() + diff --git a/src/zepben/evolve/services/network/tracing/networktrace/conditions/direction_condition.py b/src/zepben/evolve/services/network/tracing/networktrace/conditions/direction_condition.py index bd6ee0747..86021650c 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/conditions/direction_condition.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/conditions/direction_condition.py @@ -4,32 +4,60 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from __future__ import annotations -from collections.abc import Callable -from typing import TypeVar, TYPE_CHECKING, Generic +from typing import TypeVar, TYPE_CHECKING, Generic, Type +from zepben.evolve import require + +from zepben.evolve.model.cim.iec61970.base.wires.clamp import Clamp +from zepben.evolve.model.cim.iec61970.base.wires.cut import Cut + +from zepben.evolve.services.network.tracing.feeder.feeder_direction import FeederDirection from zepben.evolve.services.network.tracing.traversal.queue_condition import QueueCondition from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep if TYPE_CHECKING: - from zepben.evolve import Terminal, StepContext - from zepben.evolve.services.network.tracing.feeder.feeder_direction import FeederDirection + from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators + from zepben.evolve import StepContext T = TypeVar('T') +__all__ = ['DirectionCondition'] + class DirectionCondition(QueueCondition[NetworkTraceStep[T]], Generic[T]): - def __init__(self, direction: FeederDirection, get_direction: Callable[[Terminal], FeederDirection]): + def __init__(self, direction: FeederDirection, state_operators: Type[NetworkStateOperators]): + require(direction != FeederDirection.CONNECTOR, lambda: 'A direction of CONNECTOR is not currently supported') self.direction = direction - self.get_direction = get_direction + self.state_operators = state_operators + self.get_direction = self.state_operators.get_direction def should_queue(self, next_item: NetworkTraceStep[T], next_context: StepContext[T], current_item: NetworkTraceStep[T], current_context: StepContext[T]) -> bool: - path = next_item.path - if path.traced_internally: + return self._should_queue(next_item.path) + + def _should_queue(self, path: NetworkTraceStep.Path) -> bool: + # Cuts do weird things with directions depending on if they are energised from an external connection, or through a "closed" cut. To prevent + # dealing with this awful mess, it is much simpler to just ask if anything else past it needs queueing. This could be made to short-circuit + # for traversing downstream, but the code is much more complex to only save one extra step. + if isinstance(path.to_equipment, Cut): + return self._should_queue_next_paths(path) + elif path.traced_internally or path.did_traverse_ac_line_segment: return self.direction in self.get_direction(path.to_terminal) else: return self.direction.complementary_external_direction in self.get_direction(path.to_terminal) def should_queue_start_item(self, item: NetworkTraceStep[T]) -> bool: - return self.direction in self.get_direction(item.path.to_terminal) - + if self.direction in self.get_direction(item.path.to_terminal): + return True + # Because cuts and clamps behave a bit different with directions than other equipment terminals, we can also check if any further paths needs to be + # queued, and if they do we queue the start item. + elif isinstance(item.path.to_equipment, (Clamp, Cut)): + return self._should_queue_next_paths(item.path) + return False + + def _should_queue_next_paths(self, path: NetworkTraceStep.Path) -> bool: + for next_path in self.state_operators.next_paths(path): + if not(next_path.traced_internally and self.state_operators.is_open(path.to_equipment)): + if self._should_queue(next_path): + return True + return False diff --git a/src/zepben/evolve/services/network/tracing/networktrace/conditions/equipment_step_limit_condition.py b/src/zepben/evolve/services/network/tracing/networktrace/conditions/equipment_step_limit_condition.py index 6ea11395a..e6bfd67eb 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/conditions/equipment_step_limit_condition.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/conditions/equipment_step_limit_condition.py @@ -2,18 +2,23 @@ # 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/. -from typing import Generic, TypeVar +from __future__ import annotations + +from typing import TypeVar, TYPE_CHECKING -from zepben.evolve import StepContext, NetworkTraceStep from zepben.evolve.services.network.tracing.traversal.stop_condition import StopCondition T = TypeVar('T') +if TYPE_CHECKING: + from zepben.evolve import StepContext, NetworkTraceStep + +__all__ = ['EquipmentStepLimitCondition'] -class EquipmentStepLimitCondition(StopCondition, Generic[T]): +class EquipmentStepLimitCondition(StopCondition['NetworkTraceStep[T]']): def __init__(self, limit: int): - super().__init__(self.should_stop) + super().__init__() self.limit = limit def should_stop(self, item: NetworkTraceStep[T], context: StepContext) -> bool: - return item.num_equipment_steps >= self.limit \ No newline at end of file + return item.num_equipment_steps >= self.limit diff --git a/src/zepben/evolve/services/network/tracing/networktrace/conditions/equipment_type_step_limit_condition.py b/src/zepben/evolve/services/network/tracing/networktrace/conditions/equipment_type_step_limit_condition.py index 081d9dbba..174becbba 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/conditions/equipment_type_step_limit_condition.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/conditions/equipment_type_step_limit_condition.py @@ -13,11 +13,14 @@ from zepben.evolve import ConductingEquipment, StepContext, NetworkTraceStep T = TypeVar('T') +U = TypeVar('U') +__all__ = ['EquipmentTypeStepLimitCondition'] -class EquipmentTypeStepLimitCondition(StopConditionWithContextValue, Generic[T]): + +class EquipmentTypeStepLimitCondition(StopConditionWithContextValue[T, U], Generic[T, U]): def __init__(self, limit: int, equipment_type: Type[ConductingEquipment]): - StopConditionWithContextValue.__init__(self, _func=self.should_stop) + StopConditionWithContextValue.__init__(self, self.should_stop) TypedContextValueComputer.__init__(self, f'sdk:{equipment_type.name}Count') self.limit = limit self.equipment_type = equipment_type diff --git a/src/zepben/evolve/services/network/tracing/networktrace/conditions/network_trace_queue_condition.py b/src/zepben/evolve/services/network/tracing/networktrace/conditions/network_trace_queue_condition.py new file mode 100644 index 000000000..88a619380 --- /dev/null +++ b/src/zepben/evolve/services/network/tracing/networktrace/conditions/network_trace_queue_condition.py @@ -0,0 +1,64 @@ +# Copyright 2025 Zeppelin Bend Pty Ltd +# 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/. + +from typing import TypeVar, Generic + +from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep +from zepben.evolve.services.network.tracing.traversal.queue_condition import QueueCondition, ShouldQueue +from zepben.evolve.services.network.tracing.traversal.step_context import StepContext + +T = TypeVar('T') + +__all__ = ['NetworkTraceQueueCondition'] + + +class NetworkTraceQueueCondition(QueueCondition[NetworkTraceStep[T]], Generic[T]): + """ + A special queue condition implementation that allows only checking `should_queue` when a [NetworkTraceStep] matches a given + [NetworkTraceStep.Type]. When [step_type] is: + *[NetworkTraceStep.Type.ALL]: [should_queue] will be called for every step. + *[NetworkTraceStep.Type.INTERNAL]: [shouldQueue] will be called only when [NetworkTraceStep.type] is [NetworkTraceStep.Type.INTERNAL]. + *[NetworkTraceStep.Type.EXTERNAL]: [shouldQueue] will be called only when [NetworkTraceStep.type] is [NetworkTraceStep.Type.EXTERNAL]. + + If the step does not match the given step type, `true` will always be returned. + """ + + def __init__(self, step_type: NetworkTraceStep.Type, condition: ShouldQueue=None): + """ + :param step_type: The step type to match to check `should_queue`. + :param condition: function with the signature of `ShouldQueue` to be called when step_type matches the current items step + """ + super().__init__(self.should_queue) + if condition is not None: + self.should_queue_matched_step = condition + self.should_queue = self._should_queue_func(step_type) + + def should_queue(self, next_item: T, next_context: StepContext, current_item: T, current_context: StepContext) -> bool: + raise NotImplementedError() + + def should_queue_matched_step(self, next_item: NetworkTraceStep[T], next_context: StepContext, current_item: NetworkTraceStep[T], current_context: StepContext) -> bool: + """ + The logic you would normally put in `should_queue`. However, this will only be called when a step matches the `step_type` + """ + raise NotImplementedError() + + def should_queue_internal_step(self, next_item: NetworkTraceStep[T], next_context: StepContext, current_item: NetworkTraceStep[T], current_context: StepContext) -> bool: + if next_item.type() == NetworkTraceStep.Type.INTERNAL: + return self.should_queue_matched_step(next_item, next_context, current_item, current_context) + return True + + def should_queue_external_step(self, next_item: NetworkTraceStep[T], next_context: StepContext, current_item: NetworkTraceStep[T], current_context: StepContext) -> bool: + if next_item.type() == NetworkTraceStep.Type.EXTERNAL: + return self.should_queue_matched_step(next_item, next_context, current_item, current_context) + return True + + def _should_queue_func(self, step_type: NetworkTraceStep.Type) -> ShouldQueue: + if step_type == NetworkTraceStep.Type.ALL: + return self.should_queue_matched_step + elif step_type == NetworkTraceStep.Type.INTERNAL: + return self.should_queue_internal_step + elif step_type == NetworkTraceStep.Type.EXTERNAL: + return self.should_queue_external_step + raise ValueError(f"INTERNAL ERROR: step type [{step_type}] didn't match expected") diff --git a/src/zepben/evolve/services/network/tracing/networktrace/conditions/network_trace_stop_condition.py b/src/zepben/evolve/services/network/tracing/networktrace/conditions/network_trace_stop_condition.py new file mode 100644 index 000000000..e9c5c3e90 --- /dev/null +++ b/src/zepben/evolve/services/network/tracing/networktrace/conditions/network_trace_stop_condition.py @@ -0,0 +1,65 @@ +# Copyright 2025 Zeppelin Bend Pty Ltd +# 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/. + +from typing import TypeVar, Generic + +from zepben.evolve.services.network.tracing.traversal.step_context import StepContext +from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep +from zepben.evolve.services.network.tracing.traversal.stop_condition import StopCondition, ShouldStop + +T = TypeVar('T') + +__all__ = ['NetworkTraceStopCondition'] + + +class NetworkTraceStopCondition(StopCondition[T], Generic[T]): + """ + A special stop condition implementation that allows only checking `should_stop` when a [NetworkTraceStep] matches a given + [NetworkTraceStep.Type]. When [step_type] is: + *[NetworkTraceStep.Type.ALL]: [should_stop] will be checked for every step. + *[NetworkTraceStep.Type.INTERNAL]: [should_stop] will be checked only when [NetworkTraceStep.type] is [NetworkTraceStep.Type.INTERNAL]. + *[NetworkTraceStep.Type.EXTERNAL]: [should_stop] will be checked only when [NetworkTraceStep.type] is [NetworkTraceStep.Type.EXTERNAL]. + + If the step does not match the given step type, `false` will always be returned. + """ + + def __init__(self, step_type: NetworkTraceStep.Type, condition: ShouldStop): + """ + :param step_type: The step type to match to check `should_stop`. + :param condition: function with the signature of `ShouldStop` to be called when step_type matches the current items step + """ + super().__init__(self.should_stop) + if condition is not None: + self.should_stop_matched_step = condition + self.should_stop = self._should_stop_func(step_type) + + def should_stop(self, item: NetworkTraceStep[T], context: StepContext) -> bool: + raise NotImplementedError() + + def should_stop_matched_step(self, item: NetworkTraceStep[T], context: StepContext) -> bool: + """ + The logic you would normally put in `should_stop`. However, this will only be called when a step matches the `step_type` + """ + raise NotImplementedError() + + def should_stop_internal_step(self, item: NetworkTraceStep[T], context: StepContext) -> bool: + if item.type() == NetworkTraceStep.Type.INTERNAL: + return self.should_stop_matched_step(item, context) + return False + + def should_stop_external_step(self, item: NetworkTraceStep[T], context: StepContext) -> bool: + # We also need to check start items as they are always marked as internal, but we still want to be able to stop on them. + if (item.type() == NetworkTraceStep.Type.EXTERNAL) or context.is_start_item: + return self.should_stop_matched_step(item, context) + return False + + def _should_stop_func(self, step_type: NetworkTraceStep.Type) -> ShouldStop: + if step_type == NetworkTraceStep.Type.ALL: + return self.should_stop_matched_step + elif step_type == NetworkTraceStep.Type.INTERNAL: + return self.should_stop_internal_step + elif step_type == NetworkTraceStep.Type.EXTERNAL: + return self.should_stop_external_step + raise ValueError(f"INTERNAL ERROR: step type [{step_type}] didn't match expected") diff --git a/src/zepben/evolve/services/network/tracing/networktrace/conditions/open_condition.py b/src/zepben/evolve/services/network/tracing/networktrace/conditions/open_condition.py index 58566b27a..e6a989180 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/conditions/open_condition.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/conditions/open_condition.py @@ -9,7 +9,7 @@ from typing_extensions import TypeVar -from zepben.evolve.services.network.tracing.networktrace.network_trace_queue_condition import NetworkTraceQueueCondition +from zepben.evolve.services.network.tracing.networktrace.conditions.network_trace_queue_condition import NetworkTraceQueueCondition from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep if TYPE_CHECKING: @@ -19,6 +19,8 @@ T = TypeVar('T') +__all__ = ['OpenCondition'] + class OpenCondition(NetworkTraceQueueCondition[T], Generic[T]): def __init__(self, is_open: Callable[[Switch, SinglePhaseKind], bool], phase: SinglePhaseKind = None): @@ -33,6 +35,3 @@ def should_queue_matched_step(self, next_item: NetworkTraceStep[T], next_context return not self._is_open(equip, self._phase) else: return True - - def should_queue_start_item(self, item: T) -> bool: - return True diff --git a/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py b/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py index 21c6ec908..20dd1ac1a 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py @@ -2,9 +2,13 @@ # 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/. + from collections.abc import Callable -from typing import TypeVar, Union, Generic +from functools import singledispatchmethod +from typing import TypeVar, Union, Generic, Set, Type, Generator +from zepben.evolve.model.cim.iec61970.base.wires.clamp import Clamp +from zepben.evolve.model.cim.iec61970.base.wires.aclinesegment import AcLineSegment from zepben.evolve.model.cim.iec61970.base.core.conducting_equipment import ConductingEquipment from zepben.evolve.model.cim.iec61970.base.core.phase_code import PhaseCode from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal @@ -12,14 +16,15 @@ from zepben.evolve.services.network.tracing.networktrace.compute_data import ComputeData, ComputeDataWithPaths from zepben.evolve.services.network.tracing.networktrace.network_trace_action_type import NetworkTraceActionType -from zepben.evolve.services.network.tracing.networktrace.network_trace_queue_condition import NetworkTraceQueueCondition +from zepben.evolve.services.network.tracing.networktrace.conditions.network_trace_stop_condition import NetworkTraceStopCondition, ShouldStop +from zepben.evolve.services.network.tracing.networktrace.conditions.network_trace_queue_condition import NetworkTraceQueueCondition from zepben.evolve.services.network.tracing.networktrace.network_trace_queue_next import NetworkTraceQueueNext from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep from zepben.evolve.services.network.tracing.networktrace.network_trace_tracker import NetworkTraceTracker from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators from zepben.evolve.services.network.tracing.traversal.queue_condition import QueueCondition from zepben.evolve.services.network.tracing.traversal.step_context import StepContext -from zepben.evolve.services.network.tracing.traversal.traversal import Traversal +from zepben.evolve.services.network.tracing.traversal.traversal import Traversal, StopConditionTypes from zepben.evolve.services.network.tracing.traversal.queue import TraversalQueue from zepben.evolve.services.network.tracing.connectivity.nominal_phase_path import NominalPhasePath @@ -66,12 +71,14 @@ class NetworkTrace(Traversal[NetworkTraceStep[T], 'NetworkTrace[T]'], Generic[T] """ def __init__(self, - network_state_operators: NetworkStateOperators, + network_state_operators: Type[NetworkStateOperators], queue_type: Union[Traversal.BasicQueueType, Traversal.BranchingQueueType], parent: 'NetworkTrace[T]'=None, action_type: NetworkTraceActionType=None ): + if action_type is None: + raise ValueError('action_type can not be None') self._queue_type = queue_type self.network_state_operators = network_state_operators self._action_type = action_type @@ -82,14 +89,14 @@ def __init__(self, @classmethod def non_branching(cls, - network_state_operators: NetworkStateOperators, + network_state_operators: Type[NetworkStateOperators], queue: TraversalQueue[NetworkTraceStep[T]], action_type: NetworkTraceActionType, compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]] - ): + ) -> 'NetworkTrace[T]': return cls(network_state_operators, - Traversal.BasicQueueType(NetworkTraceQueueNext().basic( - network_state_operators.is_in_service, + Traversal.BasicQueueType(NetworkTraceQueueNext.Basic( + network_state_operators, compute_data_with_action_type(compute_data, action_type) ), queue), None, @@ -97,22 +104,24 @@ def non_branching(cls, @classmethod def branching(cls, - network_state_operators: NetworkStateOperators, + network_state_operators: Type[NetworkStateOperators], queue_factory: Callable[[], TraversalQueue[T]], branch_queue_factory: Callable[[], TraversalQueue['NetworkTrace[T]']], action_type: NetworkTraceActionType, parent: 'NetworkTrace[T]'=None, compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]]=None, - ): + ) -> 'NetworkTrace[T]': return cls(network_state_operators, - Traversal.BranchingQueueType(NetworkTraceQueueNext().branching( - network_state_operators.is_in_service, compute_data_with_action_type(compute_data, action_type) + Traversal.BranchingQueueType(NetworkTraceQueueNext.Branching( + network_state_operators, + compute_data_with_action_type(compute_data, action_type) ), queue_factory, branch_queue_factory), parent, action_type) - def add_start_item(self, start: Union[Terminal, ConductingEquipment], data: T= None, phases: PhaseCode=None) -> "NetworkTrace[T]": + @singledispatchmethod + def add_start_item(self, start: Union[Terminal, ConductingEquipment], data: T=None, phases: PhaseCode=None) -> "NetworkTrace[T]": """ Depending on the type of `start`, adds either: - A starting [Terminal] to the trace with the associated step data. @@ -124,28 +133,67 @@ def add_start_item(self, start: Union[Terminal, ConductingEquipment], data: T= N :param data: The data associated with the start step. :param phases: Phases to trace; `None` to ignore phases. """ - if isinstance(start, Terminal): - start_path = NetworkTraceStep.Path(start, start, self.start_nominal_phase_path(phases)) - super().add_start_item(NetworkTraceStep(start_path, 0, 0, data)) - return self + raise Exception('INTERNAL ERROR:: unexpected add_start_item params') - if issubclass(start.__class__, ConductingEquipment) or isinstance(start, ConductingEquipment): - for it in start.terminals: - self.add_start_item(it, data, phases) - return self + @add_start_item.register + def _(self, start: ConductingEquipment, data=None, phases=None): + # We don't have a special case for Clamp here because we say if you start from the whole Clamp rather than its terminal specifically, + # we want to trace externally from it and traverse its segment. + for it in start.terminals: + self._add_start_item(it, data, phases, None) - super().add_start_item(start) return self + @add_start_item.register + def _(self, start: Terminal, data=None, phases=None): + # We have a special case when starting specifically on a clamp terminal that we mark it as having traversed the segment such that it + # will only trace externally from the clamp terminal. This behaves differently to when the whole Clamp is added as a start item. + traversed_ac_line_segment = None + if isinstance(start.conducting_equipment, Clamp): + traversed_ac_line_segment = start.conducting_equipment.ac_line_segment + self._add_start_item(start, data, phases, traversed_ac_line_segment) + return self + + @add_start_item.register + def _(self, start: AcLineSegment, data=None, phases=None): + # If we start on an AcLineSegment, we queue the segments terminals, and all its Cut and Clamp terminals as if we have traversed the segment, + # so the next steps will be external from all the terminals "belonging" to the segment. + def start_terminals() -> Generator[Terminal, None, None]: + for terminal in start.terminals: + yield terminal + for clamp in start.clamps: + for terminal in clamp.terminals: + yield terminal + break + for cut in start.cuts: + for terminal in cut.terminals: + yield terminal + + + for terminal in start_terminals(): + self._add_start_item(terminal, data, phases, start) + + + def _add_start_item(self, + start: Terminal=None, + data: T=None, + phases: PhaseCode=None, + traversed_ac_line_segment: AcLineSegment=None): + + if start is None: + return + start_path = NetworkTraceStep.Path(start, start, traversed_ac_line_segment, self.start_nominal_phase_path(phases)) + super().add_start_item(NetworkTraceStep(start_path, 0, 0, data)) + async def run(self, start: Union[ConductingEquipment, Terminal]=None, data: T=None, phases: PhaseCode=None, can_stop_on_start_item: bool=True) -> "NetworkTrace[T]": """ Runs the network trace starting from `start` Depending on the type of `start`, this will either start from: - - A starting [Terminal] to the trace with the associated step data. - - All terminals of the given [ConductingEquipment] as starting points in the trace, with the associated data. + - A starting `Terminal` to the trace with the associated step data. + - All terminals of the given `ConductingEquipment` as starting points in the trace, with the associated data. - :param start: The starting [Terminal] or [ConductingEquipment] for the trace. + :param start: The starting `Terminal` or `ConductingEquipment` for the trace. :param data: The data associated with the start step. :param phases: Phases to trace; `None` to ignore phases. :param can_stop_on_start_item: indicates whether the trace should check stop conditions on start items. @@ -156,29 +204,77 @@ async def run(self, start: Union[ConductingEquipment, Terminal]=None, data: T=No await super().run(can_stop_on_start_item=can_stop_on_start_item) return self + @singledispatchmethod def add_condition(self, condition: QueueCondition[T]) -> "NetworkTrace[T]": + + """ + Adds a traversal condition to the trace. + + Valid types for `condition` are: + - A predefined traversal condition (eg: Conditions.stop_at_open()) + - A function implementing ShouldQueue or ShouldStop signature. + - A class subclassing StopCondition or QueueCondition + + :param condition: The condition to be added + :returns: This `NetworkTrace` instance + """ + return super().add_condition(condition) + + @add_condition.register + def _(self, condition: Callable): """ Adds a traversal condition to the trace using the trace's [NetworkStateOperators] as the receiver. This overload primarily exists to enable a DSL-like syntax for adding predefined traversal conditions to the trace. - For example, to configure the trace to stop at open points using the [Conditions.stopAtOpen] factory, you can use: + For example, to configure the trace to stop at open points using the [Conditions.stop_at_open] factory, you can use: + + >>> from zepben.evolve import stop_at_open + >>> NetworkTrace().add_condition(stop_at_open()) + """ - >>> NetworkTrace().add_condition(NetworkStateOperators.NORMAL.stop_at_open()) + if condition.__code__.co_argcount == 1: # Catches DSL Style lambda conditions from zepben.evolve.Conditions + return self.add_condition(condition(self.network_state_operators)) + return super().add_condition(condition) - :param condition: A lambda function that returns a traversal condition. - :returns: This [NetworkTrace] instance + @singledispatchmethod + def add_queue_condition(self, condition: NetworkTraceQueueCondition[NetworkTraceStep[T]], step_type: NetworkTraceStep.Type=None) -> "NetworkTrace[T]": """ - super().add_condition(condition) - return self + Adds a `QueueCondition` to the traversal. However, before registering it with the traversal, it will make sure that the queue condition + is only checked on step types relevant to the `NetworkTraceActionType` assigned to this instance. That is when: - def add_queue_condition(self, condition: Union[Callable, QueueCondition[NetworkTraceStep[T]]], step_type:NetworkTraceStep.Type=None) -> "NetworkTrace[T]": - if callable(condition): - return self.add_queue_condition(QueueCondition(condition)) + - `action_type` is `NetworkTraceActionType.ALL_STEPS` the condition will be checked on all steps. + - `action_type` is `NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT` the condition will be checked on external steps. - if step_type is None: - return super().add_queue_condition(to_network_trace_queue_condition(condition, default_queue_condition_step_type(self._action_type), False)) - else: - return super().add_queue_condition(to_network_trace_queue_condition(condition, step_type, True)) + However, if the `condition` is an instance of `NetworkTraceQueueCondition` the `NetworkTraceQueueCondition.step_type` will be honoured. + + :param condition: The queue condition to add. + :returns: This `NetworkTrace` instance + """ + return super().add_queue_condition(condition) + + @add_queue_condition.register + def _(self, condition: Callable, step_type: NetworkTraceStep.Type=None): + return self.add_queue_condition(NetworkTraceQueueCondition(default_condition_step_type(self._action_type) or step_type, condition)) + + @singledispatchmethod + def add_stop_condition(self, condition: StopConditionTypes, step_type: NetworkTraceStep.Type=None) -> "NetworkTrace[T]": + """ + Adds a `StopCondition` to the traversal. However, before registering it with the traversal, it will make sure that the queue condition + is only checked on step types relevant to the `NetworkTraceActionType` assigned to this instance. That is when: + + - `action_type` is `NetworkTraceActionType.ALL_STEPS` the condition will be checked on all steps. + - `action_type` is `NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT` the condition will be checked on external steps. + + However, if the `condition` is an instance of `NetworkTraceStopCondition` the `NetworkTraceStopCondition.step_type` will be honoured. + + :param condition: The stop condition to add. + :returns: This `NetworkTrace` instance + """ + return super().add_stop_condition(condition) + + @add_stop_condition.register(Callable) + def _(self, condition: ShouldStop, step_type=None): + return self.add_stop_condition(NetworkTraceStopCondition(default_condition_step_type(self._action_type) or step_type, condition)) def can_action_item(self, item: T, context: StepContext) -> bool: return self._action_type(item, context, self.has_visited) @@ -196,8 +292,8 @@ def create_new_this(self) -> 'NetworkTrace[T]': return NetworkTrace(self.network_state_operators, self._queue_type, self, self._action_type) @staticmethod - def start_nominal_phase_path(phases: PhaseCode) -> list[NominalPhasePath]: - return [NominalPhasePath(it, it) for it in phases.single_phases] if phases and phases.single_phases else [] + def start_nominal_phase_path(phases: PhaseCode) -> Set[NominalPhasePath]: + return {NominalPhasePath(it, it) for it in phases.single_phases} if phases and phases.single_phases else set() def has_visited(self, terminal: Terminal, phases: set[SinglePhaseKind]) -> bool: parent = self.parent @@ -205,27 +301,17 @@ def has_visited(self, terminal: Terminal, phases: set[SinglePhaseKind]) -> bool: if parent._tracker.has_visited(terminal, phases): return True parent = parent.parent - return self._tracker.has_visited(terminal, phases) def visit(self, terminal: Terminal, phases: set[SinglePhaseKind]) -> bool: - parent = self.parent - while parent is not None: - if parent._tracker.has_visited(terminal, phases): + if self.parent and self.parent.has_visited(terminal, phases): return False - parent = parent.parent - return self._tracker.visit(terminal, phases) -def to_network_trace_queue_condition(queue_condition: QueueCondition[NetworkTraceStep[T]], step_type: NetworkTraceStep.Type, override_step_type: bool): - if isinstance(queue_condition, NetworkTraceQueueCondition) and not override_step_type: - return queue_condition - else: - return NetworkTraceQueueCondition.delegate_to(step_type, queue_condition) - - -def default_queue_condition_step_type(step_type): +def default_condition_step_type(step_type): + if step_type is None: + return False if step_type == NetworkTraceActionType.ALL_STEPS: return NetworkTraceStep.Type.ALL elif step_type == NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT: diff --git a/src/zepben/evolve/services/network/tracing/networktrace/network_trace_action_type.py b/src/zepben/evolve/services/network/tracing/networktrace/network_trace_action_type.py index 380083522..e793a74e7 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace_action_type.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace_action_type.py @@ -1,46 +1,40 @@ -# Copyright 2024 Zeppelin Bend Pty Ltd +# Copyright 2025 Zeppelin Bend Pty Ltd # 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/. - +from typing import Callable, Set, Any from enum import Enum +from zepben.evolve import Terminal, SinglePhaseKind from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep from zepben.evolve.services.network.tracing.traversal.step_context import StepContext +HasTracked = Callable[[Terminal, Set[SinglePhaseKind]], bool] +CanActionItem = Callable[[NetworkTraceStep[Any], StepContext, HasTracked], bool] -class EnumFunc: - def __init__(self, func): - self._func = func - - def __call__(self, *args, **kwargs): - return self._func(*args, **kwargs) - -def _all_steps(item: NetworkTraceStep, context: StepContext, has_tracked) -> bool: +def _all_steps(item: NetworkTraceStep, context: StepContext, has_tracked: HasTracked) -> bool: return True -def _first_step_on_equipment(item: NetworkTraceStep, context: StepContext, has_tracked) -> bool: - for ot in item.path.to_terminal.other_terminals(): - if has_tracked(ot, item.path.to_phases_set()): - return False - return True +def _first_step_on_equipment(item: NetworkTraceStep[Any], context: StepContext, has_tracked: HasTracked) -> bool: + phases = item.path.to_phases_set() + return not any(has_tracked(it, phases) for it in item.path.to_terminal.other_terminals()) class NetworkTraceActionType(Enum): """ Options to configure when a [NetworkTrace] actions a [NetworkTraceStep]. """ - def __call__(self, *args, **kwargs): + def __call__(self, *args, **kwargs) -> bool: return self.value(*args, **kwargs) - ALL_STEPS = EnumFunc(_all_steps) + ALL_STEPS: CanActionItem = _all_steps """ All steps visited during a [NetworkTrace] will be actioned. """ - FIRST_STEP_ON_EQUIPMENT = EnumFunc(_first_step_on_equipment) + FIRST_STEP_ON_EQUIPMENT: CanActionItem = _first_step_on_equipment """ Only actions steps where the `toEquipment` on the [NetworkTraceStep.path] has not been visited before on the phases within the [NetworkTraceStep.path]. This means that all [NetworkTraceStep.type] of [NetworkTraceStep.Type.INTERNAL] will never be actioned as a first visit will always occur on an diff --git a/src/zepben/evolve/services/network/tracing/networktrace/network_trace_queue_condition.py b/src/zepben/evolve/services/network/tracing/networktrace/network_trace_queue_condition.py deleted file mode 100644 index ecc838813..000000000 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace_queue_condition.py +++ /dev/null @@ -1,58 +0,0 @@ -# Copyright 2025 Zeppelin Bend Pty Ltd -# 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/. -from typing import TypeVar, Generic - -from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep -from zepben.evolve.services.network.tracing.traversal.queue_condition import QueueCondition -from zepben.evolve.services.network.tracing.traversal.step_context import StepContext - -T = TypeVar('T') - - -class NetworkTraceQueueCondition(QueueCondition[NetworkTraceStep[T]], Generic[T]): - step_type:NetworkTraceStep.Type - - def __init__(self, step_type: NetworkTraceStep.Type): - self.should_queue_func = { - NetworkTraceStep.Type.ALL: self.should_queue_matched_step, - NetworkTraceStep.Type.INTERNAL: self.should_queue_internal_step, - NetworkTraceStep.Type.EXTERNAL: self.should_queue_external_step - }.get(step_type) - - - def should_queue(self, next_item: T, next_context: StepContext, current_item: T, current_context: StepContext) -> bool: - """ - interface to call the correct `self.should_queue_****_step` function as defined by `self.should_queue_func` - """ - return self.should_queue_func(next_item, next_context, current_item, current_context) - - def should_queue_matched_step(self, next_item: NetworkTraceStep[T], next_context: StepContext, current_item: NetworkTraceStep[T], current_context: StepContext) -> bool: - raise NotImplementedError() - - def should_queue_internal_step(self, next_item: NetworkTraceStep[T], next_context: StepContext, current_item: NetworkTraceStep[T], current_context: StepContext) -> bool: - if next_item.type() == NetworkTraceStep.Type.INTERNAL: - return self.should_queue_matched_step(next_item, next_context, current_item, current_context) - return True - - def should_queue_external_step(self, next_item: NetworkTraceStep[T], next_context: StepContext, current_item: NetworkTraceStep[T], current_context: StepContext) -> bool: - if next_item.type() == NetworkTraceStep.Type.EXTERNAL: - return self.should_queue_matched_step(next_item, next_context, current_item, current_context) - return True - - @staticmethod - def delegate_to(step_type: NetworkTraceStep.Type, condition: QueueCondition[NetworkTraceStep[T]]) -> 'NetworkTraceQueueCondition[T]': - return DelegatedNetworkTraceQueueCondition(step_type, condition) - - -class DelegatedNetworkTraceQueueCondition(NetworkTraceQueueCondition[T], Generic[T]): - def __init__(self, step_type: NetworkTraceStep.Type, delegate: QueueCondition[NetworkTraceStep[T]]): - super().__init__(step_type) - self.delegate = delegate - - def should_queue_matched_step(self, next_item: NetworkTraceStep[T], next_context: StepContext, current_item: NetworkTraceStep[T], current_context: StepContext) -> bool: - return self.delegate.should_queue(next_item, next_context, current_item, current_context) - - def should_queue_start_item(self, item: NetworkTraceStep[T]) -> bool: - return self.delegate.should_queue_start_item(item) \ No newline at end of file diff --git a/src/zepben/evolve/services/network/tracing/networktrace/network_trace_queue_next.py b/src/zepben/evolve/services/network/tracing/networktrace/network_trace_queue_next.py index 723f57882..3a60800d7 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace_queue_next.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace_queue_next.py @@ -2,96 +2,83 @@ # 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/. -from typing import TypeVar, Callable, Sequence, Iterable, Generator -from zepben.evolve import TerminalConnectivityConnected -from zepben.evolve.model.cim.iec61970.base.core.conducting_equipment import ConductingEquipment -from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal -from zepben.evolve.model.cim.iec61970.base.wires.connectors import BusbarSection +from abc import ABC +from typing import TypeVar, Callable, Generator, Generic, List, Union, Type -from zepben.evolve.services.network.tracing.traversal.step_context import StepContext -from zepben.evolve.services.network.tracing.networktrace.compute_data import ComputeData +from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep from zepben.evolve.services.network.tracing.traversal.traversal import Traversal +from zepben.evolve.services.network.tracing.networktrace.compute_data import ComputeData, ComputeDataWithPaths +from zepben.evolve.services.network.tracing.traversal.step_context import StepContext T = TypeVar('T') -CheckInService = Callable[[ConductingEquipment], bool] +QueueItem = Callable[[NetworkTraceStep[T]], bool] +QueueBranch = Callable[[NetworkTraceStep[T]], bool] +GetNextSteps = Callable[[NetworkTraceStep[T], StepContext], Generator[NetworkTraceStep[T], None, None]] +GetNextStepsBranching = Callable[[NetworkTraceStep[T], StepContext], List[NetworkTraceStep[T]]] -class NetworkTraceQueueNext: - def basic(self, is_in_service: CheckInService, compute_data: ComputeData[T]) -> Traversal.QueueNext[NetworkTraceStep[T]]: - return Traversal.QueueNext(lambda item, context, queue_item: list(map(queue_item ,self._next_trace_steps(is_in_service, item, context, compute_data)))) +class NetworkTraceQueueNext(ABC): + state_operators = NetworkStateOperators - def branching(self, is_in_service: CheckInService, compute_data: ComputeData[T]) -> Traversal.BranchingQueueNext[NetworkTraceStep[T]]: - return Traversal.BranchingQueueNext(lambda item, context, queue_item, queue_branch: self._queue_next_steps_branching(list(self._next_trace_steps(is_in_service, item, context, compute_data)), queue_item, queue_branch)) + def __init__(self, state_operators: Type[NetworkStateOperators]): + self.state_operators = state_operators - @staticmethod - def _queue_next_steps_branching(next_steps: list[NetworkTraceStep[T]], - queue_item: Callable[[NetworkTraceStep[T]], bool], - queue_branch: Callable[[NetworkTraceStep[T]], bool]): - if len(next_steps) == 1: - return queue_item(next_steps[0]) - else: - return [queue_branch(step) for step in next_steps] - def _next_trace_steps(self, - is_in_service: CheckInService, + def next_trace_steps(self, current_step: NetworkTraceStep[T], current_context: StepContext, - compute_data: ComputeData[T] - ) -> Sequence[NetworkTraceStep[T]]: + compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]] + ) -> Generator[NetworkTraceStep[T], None, None]: """ Builds a list of next `NetworkTraceStep` to add to the `NetworkTrace` queue """ + next_paths = list(self.state_operators.next_paths(current_step.path)) + if isinstance(compute_data, ComputeData): + compute_next = lambda _it: compute_data.compute_next(current_step, current_context, _it) + elif isinstance(compute_data, ComputeDataWithPaths): + compute_next = lambda _it: compute_data.compute_next(current_step, current_context, _it, next_paths) + else: + raise TypeError(f'ComputeData was not of a recognised class: {compute_data.__class__} not in [ComputeData, ComputeDataWithPaths]') next_num_terminal_steps = current_step.next_num_terminal_steps() - next_num_equipment_steps = current_step.next_num_equipment_steps() - return list(NetworkTraceStep( - path, - next_num_terminal_steps, - next_num_equipment_steps, - compute_data.compute_next(current_step, current_context, path) - ) for path in self._next_step_paths(is_in_service, current_step.path)) + for it in next_paths: + data = compute_next(it) + yield NetworkTraceStep(it, next_num_terminal_steps, it.next_num_equipment_steps(current_step.num_equipment_steps), data) - def _next_step_paths(self, is_in_service: CheckInService, path: NetworkTraceStep.Path) -> Generator[NetworkTraceStep.Path, None, None]: - next_terminals = self._next_terminals(is_in_service, path) + @staticmethod + def Basic(state_operators: Type[NetworkStateOperators], compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]]): + return Basic(state_operators, compute_data) - if len(path.nominal_phase_paths) > 0: - phase_paths = set(it.to_phase for it in path.nominal_phase_paths) + @staticmethod + def Branching(state_operators: Type[NetworkStateOperators], compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]]): + return Branching(state_operators, compute_data) - for result in (TerminalConnectivityConnected().terminal_connectivity(path.to_terminal, t, phase_paths) for t in next_terminals): - if result.nominal_phase_paths: - yield NetworkTraceStep.Path(path.to_terminal, result.to_terminal, result.nominal_phase_paths) - else: - for terminal in next_terminals: - yield NetworkTraceStep.Path(path.to_terminal, terminal) +class Basic(NetworkTraceQueueNext, Traversal.QueueNext[NetworkTraceStep[T]], Generic[T]): + def __init__(self, state_operators: Type[NetworkStateOperators], compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]]): + super().__init__(state_operators) - @staticmethod - def _next_terminals(is_in_service: CheckInService, path: NetworkTraceStep.Path) -> Iterable[Terminal]: - def __next_terminals(): - if path.traced_internally: - # We need to step externally to connected terminals. However: - # Busbars are only modelled with a single terminal. So if we find any we need to step to them before the - # other (non busbar) equipment connected to the same connectivity node. Once the busbar has been - # visited we then step to the other non busbar terminals connected to the same connectivity node. - if path.to_terminal.has_connected_busbars(): - return (t for t in path.to_terminal.connected_terminals() if t.conducting_equipment is BusbarSection) - else: - return path.to_terminal.connected_terminals() - - else: - # If we just visited a busbar, we step to the other terminals that share the same connectivity node. - # Otherwise, we internally step to the other terminals on the equipment - if path.to_equipment is BusbarSection: - # We don't need to step to terminals that are busbars as they would have been queued at the same time this busbar step was. - # We also don't try and go back to the terminals we came from as we already visited it to get to this busbar. - return (t for t in path.to_terminal.connected_terminals() if t != path.from_terminal and t.conducting_equipment is not BusbarSection) - else: - return path.to_terminal.other_terminals() - - def _filter(it: Terminal) -> bool: - if it.conducting_equipment: - return is_in_service(it.conducting_equipment) - return False - - return (t for t in __next_terminals() if _filter(t)) + self._get_next_steps: GetNextSteps = lambda item, context: self.next_trace_steps(item, context, compute_data) + + def __iinit__(self, get_next_steps: GetNextSteps): + self._get_next_steps: GetNextSteps = get_next_steps + + def accept(self, item: NetworkTraceStep[T], context: StepContext, queue_item: QueueItem): + for it in self._get_next_steps(item, context): + queue_item(it) + + +class Branching(NetworkTraceQueueNext, Traversal.BranchingQueueNext[NetworkTraceStep[T]], Generic[T]): + def __init__(self, state_operators: Type[NetworkStateOperators], compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]]): + super().__init__(state_operators) + + self._get_next_steps: GetNextStepsBranching = lambda item, context: list(self.next_trace_steps(item, context, compute_data)) + + def accept(self, item: NetworkTraceStep[T], context: StepContext, queue_item: QueueItem, queue_branch: QueueBranch): + next_steps = self._get_next_steps(item, context) + if len(next_steps) == 1: + queue_item(next_steps[0]) + else: + for step in next_steps: + queue_branch(step) diff --git a/src/zepben/evolve/services/network/tracing/networktrace/network_trace_step.py b/src/zepben/evolve/services/network/tracing/networktrace/network_trace_step.py index 2fb4d9e87..5b469db9b 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace_step.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace_step.py @@ -6,14 +6,15 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Set, Generic, TypeVar, TYPE_CHECKING, List +from typing import Set, Generic, TypeVar, TYPE_CHECKING, Optional, List -from zepben.evolve import SinglePhaseKind from zepben.evolve.services.network.tracing.connectivity.nominal_phase_path import NominalPhasePath if TYPE_CHECKING: from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal from zepben.evolve.model.cim.iec61970.base.core.conducting_equipment import ConductingEquipment + from zepben.evolve.model.cim.iec61970.base.wires.aclinesegment import AcLineSegment + from zepben.evolve.model.cim.iec61970.base.wires.single_phase_kind import SinglePhaseKind T = TypeVar('T') @@ -33,29 +34,32 @@ class Path: """ Represents the path taken in a network trace step, detailing the transition from one terminal to another. - A limitation of the network trace is that all terminals must have associated conducting equipment. This means that if the [fromTerminal] - or [toTerminal] have `null` conducting equipment an [IllegalStateException] will be thrown. + A limitation of the network trace is that all terminals must have associated conducting equipment. This means that if the `from_terminal` + or `to_terminal` have `None` conducting equipment an [IllegalStateException] will be thrown. - `fromTerminal` The terminal that was stepped from. - `toTerminal` The terminal that was stepped to. - `nominalPhasePaths` A list of nominal phase paths traced in this step. If this is empty, phases have been ignored. - `fromEquipment` The conducting equipment associated with the [fromTerminal]. - `toEquipment` The conducting equipment associated with the [toTerminal]. - `tracedInternally` `true` if the from and to terminals belong to the same equipment; `false` otherwise. - `tracedExternally` `true` if the from and to terminals belong to different equipment; `false` otherwise. + No validation is done on the `traversed_ac_line_segment` against the `from_terminal` and `to_terminal`. It assumes the creator knows what they are doing + and thus avoids the overhead of validation as this class will have lots if instances created as part of a [NetworkTrace]. + + :param from_terminal: The terminal that was stepped from. + :param to_terminal: The terminal that was stepped to. + :param traversed_ac_line_segment: If the from_terminal and to_terminal path was via an `AcLineSegment`, this is the segment that was traversed + :param nominal_phase_paths: A list of nominal phase paths traced in this step. If this is empty, phases have been ignored. """ from_terminal: Terminal to_terminal: Terminal - nominal_phase_paths: List[NominalPhasePath] = field(default_factory=list) + traversed_ac_line_segment: Optional[AcLineSegment] = field(default=None) + nominal_phase_paths: Optional[Set[NominalPhasePath]] = field(default_factory=set) def to_phases_set(self) -> Set[SinglePhaseKind]: if len(self.nominal_phase_paths) == 0: return set() return set(map(lambda it: it.to_phase, self.nominal_phase_paths)) - @property def from_equipment(self) -> ConductingEquipment: + """ + The conducting equipment associated with `self.from_terminal`. + """ ce = self.from_terminal.conducting_equipment if not ce: raise AttributeError("Network trace does not support terminals that do not have conducting equipment") @@ -63,6 +67,9 @@ def from_equipment(self) -> ConductingEquipment: @property def to_equipment(self) -> ConductingEquipment: + """ + The conducting equipment associated with `self.to_terminal`. + """ ce = self.to_terminal.conducting_equipment if not ce: raise AttributeError("Network trace does not support terminals that do not have conducting equipment") @@ -70,12 +77,25 @@ def to_equipment(self) -> ConductingEquipment: @property def traced_internally(self) -> bool: + """ + `True` if the from and to terminals belong to the same equipment; `False` otherwise. + """ return self.from_equipment == self.to_equipment @property def traced_externally(self) -> bool: + """ + `True` if the from and to terminals belong to different equipment; `False` otherwise. + """ return not self.traced_internally + @property + def did_traverse_ac_line_segment(self) -> bool: + return self.traversed_ac_line_segment is not None + + def next_num_equipment_steps(self, current_num: int) -> int: + return current_num + 1 if self.traced_externally else current_num + Type = Enum('Type', ('ALL', 'INTERNAL', 'EXTERNAL')) @@ -85,7 +105,7 @@ def __init__(self, path: Path, num_terminal_steps: int, num_equipment_steps: int self.num_equipment_steps = num_equipment_steps self.data = data - def type(self) -> Path: + def type(self) -> Type: """ Returns the [Type] of the step. This will be [Type.INTERNAL] if [Path.tracedInternally] is true, [Type.EXTERNAL] when [Path.tracedExternally] is true and will never be [Type.ALL] which is used in other NetworkTrace functionality to determine if all steps should be used for that particular function. @@ -96,6 +116,3 @@ def type(self) -> Path: def next_num_terminal_steps(self): return self.num_terminal_steps + 1 - - def next_num_equipment_steps(self): - return self.num_equipment_steps + 1 if self.path.traced_internally else self.num_equipment_steps diff --git a/src/zepben/evolve/services/network/tracing/networktrace/network_trace_step_path_provider.py b/src/zepben/evolve/services/network/tracing/networktrace/network_trace_step_path_provider.py new file mode 100644 index 000000000..28f67620e --- /dev/null +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace_step_path_provider.py @@ -0,0 +1,349 @@ +# Copyright 2025 Zeppelin Bend Pty Ltd +# 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/. + +from __future__ import annotations + +import sys +from typing import Generator, Optional, Callable, Iterable, List, Union, Type, TYPE_CHECKING + +from zepben.evolve.model.cim.iec61970.base.wires.clamp import Clamp +from zepben.evolve.model.cim.iec61970.base.wires.connectors import BusbarSection +from zepben.evolve.model.cim.iec61970.base.wires.cut import Cut +from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal +from zepben.evolve.model.cim.iec61970.base.wires.aclinesegment import AcLineSegment +from zepben.evolve.services.network.tracing.connectivity.terminal_connectivity_connected import TerminalConnectivityConnected +from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep + +if TYPE_CHECKING: + from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators + +__all__ = ['NetworkTraceStepPathProvider'] + +PathFactory = Callable[[Terminal, AcLineSegment], Optional[NetworkTraceStep.Path]] + + +class NetworkTraceStepPathProvider: + def __init__( + self, + state_operators: Type[NetworkStateOperators] + ): + self.state_operators = state_operators + + def next_paths( + self, + path: NetworkTraceStep.Path + ) -> Generator[NetworkTraceStep.Path, None, None]: + + path_factory = (self._create_path_with_phases_factory(path) if path.nominal_phase_paths + else self._create_path_factory(path)) + + def _get_next_paths(): + to_equipment = path.to_equipment + if isinstance(to_equipment, AcLineSegment): + return self._next_paths_from_ac_line_segment(to_equipment, path, path_factory) + elif isinstance(to_equipment, BusbarSection): + return self._next_paths_from_busbar(path, path_factory) + elif isinstance(to_equipment, Clamp): + return self._next_paths_from_clamp(to_equipment, path, path_factory) + elif isinstance(to_equipment, Cut): + return self._next_paths_from_cut(to_equipment, path, path_factory) + + elif path.traced_internally: + return self._next_external_paths(path, path_factory) + else: + return seq_term_map_to_path(path.to_terminal.other_terminals(), path_factory) + + return (p for p in _get_next_paths() if p and self.state_operators.is_in_service(p.to_terminal.conducting_equipment)) + + @staticmethod + def _create_path_factory( + path: NetworkTraceStep.Path + ) -> PathFactory: + + def path_factory(next_terminal: Terminal, traversed: AcLineSegment) -> NetworkTraceStep.Path: + return NetworkTraceStep.Path(path.to_terminal, next_terminal, traversed) + return path_factory + + @staticmethod + def _create_path_with_phases_factory( + path: NetworkTraceStep.Path + ) -> PathFactory: + + phase_paths = set(p.to_phase for p in path.nominal_phase_paths) + next_from_terminal = path.to_terminal + + def path_factory(next_terminal: Terminal, traversed: AcLineSegment): + next_paths = TerminalConnectivityConnected().terminal_connectivity(next_from_terminal, next_terminal, phase_paths) + if next_paths.nominal_phase_paths: + return NetworkTraceStep.Path(next_from_terminal, next_terminal, traversed, set(next_paths.nominal_phase_paths)) + else: + return None + + return path_factory + + def _next_paths_from_ac_line_segment( + self, + segment: AcLineSegment, + path: NetworkTraceStep.Path, + path_factory: PathFactory + ) -> Generator[NetworkTraceStep.Path, None, None]: + + # If the current path traversed the segment, we need to step externally from the segment terminal. + # Otherwise, we traverse the segment + if path.traced_internally or path.did_traverse_ac_line_segment: + yield from self._next_external_paths(path, path_factory) + else: + if path.to_terminal.sequence_number == 1: + yield from self._acls_traverse_from_terminal( + segment, + path.to_terminal, + length_from_t1=0.0, + towards_segment_t2=True, + can_stop_at_cut_at_same_position=True, + cut_at_same_position_from_terminal_number=1, + path_factory=path_factory) + else: + yield from self._acls_traverse_from_terminal( + segment, + path.to_terminal, + length_from_t1=acls_length_or_max(segment), + towards_segment_t2=False, + can_stop_at_cut_at_same_position=True, + cut_at_same_position_from_terminal_number=2, + path_factory=path_factory) + + @staticmethod + def _next_paths_from_busbar( + path: NetworkTraceStep.Path, + path_factory: PathFactory + ) -> Generator[NetworkTraceStep.Path, None, None]: + + yield from seq_term_map_to_path( + (t for t in path.to_terminal.connected_terminals() + # We don't go back to the terminal we came from as we already visited it to get to this busbar. + if t != path.from_terminal + # We don't step to terminals that are busbars as they would have been returned at the same time this busbar step was. + and not isinstance(t.conducting_equipment, BusbarSection) + ), path_factory + ) + + def _next_paths_from_clamp( + self, + clamp: Clamp, + path: NetworkTraceStep.Path, + path_factory: PathFactory + ) -> Generator[NetworkTraceStep.Path, None, None]: + + # If the current path was from traversing an AcLineSegment, we need to step externally to other equipment. + # Otherwise, we need to traverse the segment both ways. + if path.did_traverse_ac_line_segment: + yield from self._next_external_paths(path, path_factory) + return + + elif path.traced_internally: + yield from self._next_external_paths(path, path_factory) + yield from self._traverse_ac_line_segment_from_clamp(clamp, path, path_factory) + + def _traverse_ac_line_segment_from_clamp( + self, + clamp: Clamp, + path: NetworkTraceStep.Path, + path_factory: PathFactory + ) -> Generator[NetworkTraceStep.Path, None, None]: + + # Because we consider clamps at the same position as a cut on the terminal 1 side, we do not stop at cuts at the same position when + # traversing towards t1, but we do when traversing towards t2. + if not clamp.ac_line_segment: + return + + _yielded_paths = set() + + def _mark(_path): + _yielded_paths.add(_path.to_terminal) + return _path + + yield from ( + _mark(path) for path in self._acls_traverse_from_terminal( + clamp.ac_line_segment, + path.to_terminal, + length_from_t1=clamp.length_from_T1_or_0, + towards_segment_t2=False, + can_stop_at_cut_at_same_position=False, + cut_at_same_position_from_terminal_number=1, + path_factory=path_factory + ) + ) + + yield from ( + _mark(path) for path in self._acls_traverse_from_terminal( + clamp.ac_line_segment, + path.to_terminal, + length_from_t1=clamp.length_from_T1_or_0, + towards_segment_t2=True, + can_stop_at_cut_at_same_position=True, + cut_at_same_position_from_terminal_number=1, + path_factory=path_factory + ) if path.to_terminal not in _yielded_paths + ) + + def _next_paths_from_cut( + self, + cut: Cut, + path: NetworkTraceStep.Path, + path_factory: PathFactory + ) -> Iterable[NetworkTraceStep.Path]: + + # If the current path was from traversing an AcLineSegment, we need to step externally to other equipment. + if path.did_traverse_ac_line_segment: + yield from self._next_external_paths(path, path_factory) + # Else we need to traverse the segment. + elif cut.ac_line_segment: + yield from self._acls_traverse_from_terminal( + cut.ac_line_segment, + path.to_terminal, + length_from_t1=cut.length_from_T1_or_0, + towards_segment_t2=path.to_terminal.sequence_number != 1, + can_stop_at_cut_at_same_position=False, + cut_at_same_position_from_terminal_number=path.to_terminal.sequence_number, + path_factory=path_factory + ) + + # If the current path traced internally, we need to also return the external terminals + if path.traced_internally: + # traversedAcLineSegment and tracedInternally should never both be true, so we should never get external terminals twice + yield from self._next_external_paths(path, path_factory) + # Else we need to step internally to the Cut's other terminal. + else: + other_terminal = cut.get_terminal_by_sn(2 if path.to_terminal.sequence_number == 1 else 1) + yield from seq_term_map_to_path(other_terminal, path_factory) + + + def _next_external_paths( + self, + path: NetworkTraceStep.Path, + path_factory: PathFactory + ) -> Generator[NetworkTraceStep.Path, None, None]: + + #Busbars are only modelled with a single terminal. So if we find any we need to step to them before the + #other (non busbar) equipment connected to the same connectivity node. Once the busbar has been + #visited we then step to the other non busbar terminals connected to the same connectivity node. + #If there are no busbars we can just step to all other connected terminals. + if isinstance(path.to_equipment, BusbarSection): + yield from self._next_paths_from_busbar(path, path_factory) + elif path.to_terminal.has_connected_busbars(): + yield from seq_term_map_to_path((t for t in path.to_terminal.connected_terminals() if isinstance(t.conducting_equipment, BusbarSection)), path_factory) + else: + yield from seq_term_map_to_path(path.to_terminal.connected_terminals(), path_factory) + + def _acls_traverse_from_terminal( + self, + acls: AcLineSegment, + from_terminal: Terminal, + length_from_t1: float, + towards_segment_t2: bool, + can_stop_at_cut_at_same_position: bool, + cut_at_same_position_from_terminal_number: int, + path_factory: PathFactory + ) -> Generator[NetworkTraceStep.Path, None, None]: + """ + This returns terminals found traversing along an AcLineSegment from any terminal "on" the segment. Terminals considered on the segment are any clamp + or cut terminals that belong to the segment as well as the segment's own terminals. When traversing the segment, the traversal stops + at and returns the next cut terminal found along the segment plus any clamp terminals it found between the fromTerminal and the cut terminal. + If there are no cuts on the segment the terminal, the other end of the segment is returned along with all clamp terminals. + To determine order of terminals on the segment, `lengthFromTerminal1` is used for cuts and clamps. When this property is null a default value of 0.0 is + assumed, effectively placing it at the start of the segment. Terminal 1 on the segment is deemed at 0.0 and Terminal 2 is deemed at + [AcLineSegment.length] or [Double.MAX_VALUE] if the length or the segment is `None`. + + This algorithm assumes AcLineSegments have exactly 2 terminals, cuts have exactly 2 terminals and clamps have exactly 1 terminal. + + If there is a cut and a clamp at the exact same length on the segment, it is assumed the clamp is on the terminal 1 side of the cut. This is so you do not + get the clamp twice when traversing a segment from one end to the other. As a clamp can't technically be in the exact same spot as a cut, you should + realistically model this either attaching the equipment attached by the clamp to the appropriate cut terminal, or, place a clamp at a length that is + not exactly the same as the cut. This would yield more accurate and deterministic behaviour. + + :param from_terminal: The terminal on the segment to traverse from. This could either be a segment terminal, or a terminal from any cut or clamp on the segment. + :param length_from_t1: The length from terminal 1 the fromTerminal is. + :param towards_segment_t2: Use `true` if the segment should be traversed towards terminal 2, otherwise `False` to traverse towards terminal 1 + """ + + cuts, clamps = list(acls.cuts), list(acls.clamps) + + # Can do a simple return if we don't need to do any special cuts/clamps processing + if not any((cuts, clamps)): + yield from seq_term_map_to_path(from_terminal.other_terminals(), path_factory, acls) + return + + # We need to ignore cuts and clamps that are not "in service" because that means they do not exist! + # We also make sure we filter out the cut or the clamp we are starting at, so we don't compare it in our checks + filter_func = lambda it: it != from_terminal.conducting_equipment and self.state_operators.is_in_service(it) + cuts: List[Cut] = list(filter(filter_func, cuts)) + clamps: List[Clamp] = list(filter(filter_func, clamps)) + + cuts_at_same_position = [it for it in cuts if it.length_from_T1_or_0 == length_from_t1] + stop_at_cuts_at_same_position = bool(can_stop_at_cut_at_same_position and cuts_at_same_position) + + def next_cut_length_from_terminal_1_func(): + if stop_at_cuts_at_same_position: + return length_from_t1 + elif towards_segment_t2: + return min((it.length_from_T1_or_0 for it in cuts if it.length_from_T1_or_0 > length_from_t1), default=None) + else: + return max((it.length_from_T1_or_0 for it in cuts if it.length_from_T1_or_0 < length_from_t1), default=None) + + next_cut_length_from_terminal_1 = next_cut_length_from_terminal_1_func() + + next_cuts = [it for it in cuts if it.length_from_T1_or_0 == next_cut_length_from_terminal_1] if next_cut_length_from_terminal_1 is not None else [] + + def next_term_length_from_term_1_func(): + if next_cut_length_from_terminal_1 is not None: + return next_cut_length_from_terminal_1 + elif towards_segment_t2: + return acls_length_or_max(acls) + else: + return 0.0 + + next_terminal_length_from_terminal_1 = next_term_length_from_term_1_func() + + def clamps_before_next_terminal_filter() -> Callable[[Clamp], bool]: + if isinstance(from_terminal.conducting_equipment, AcLineSegment) and towards_segment_t2: + return lambda it: length_from_t1 <= it.length_from_T1_or_0 <= next_terminal_length_from_terminal_1 + elif towards_segment_t2: + return lambda it: length_from_t1 < it.length_from_T1_or_0 <= next_terminal_length_from_terminal_1 + elif (next_terminal_length_from_terminal_1 == 0.0) and len(next_cuts) == 0: + return lambda it: next_terminal_length_from_terminal_1 <= it.length_from_T1_or_0 <= length_from_t1 + else: + return lambda it: length_from_t1 >= it.length_from_T1_or_0 > next_terminal_length_from_terminal_1 + + _filter = clamps_before_next_terminal_filter() + + clamps_before_next_terminal = (c for c in clamps if _filter(c)) + + next_stop_terminals = [] if stop_at_cuts_at_same_position else ( + it.get_terminal(1 if towards_segment_t2 else 2) for it in next_cuts + ) if next_cuts else [acls.get_terminal(2 if towards_segment_t2 else 1)] + + next_terminals = ( + (it.get_terminal(cut_at_same_position_from_terminal_number) for it in cuts_at_same_position), + (it.get_terminal(1) for it in clamps_before_next_terminal), + next_stop_terminals + ) + + for generator in next_terminals: + yield from seq_term_map_to_path(generator, path_factory, acls) + +def seq_term_map_to_path( + terms: Union[Terminal, Iterable[Terminal]], + path_factory: PathFactory, + traversed_acls: AcLineSegment=None +) -> Generator[NetworkTraceStep.Path, None, None]: + + if isinstance(terms, Iterable): + for terminal in terms: + if terminal is not None: + yield path_factory(terminal, traversed_acls) + else: + yield path_factory(terms, traversed_acls) + +acls_length_or_max = lambda acls: acls.length or sys.float_info.max diff --git a/src/zepben/evolve/services/network/tracing/networktrace/network_trace_tracker.py b/src/zepben/evolve/services/network/tracing/networktrace/network_trace_tracker.py index d0677c154..f14c41728 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace_tracker.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace_tracker.py @@ -4,7 +4,8 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from typing import Set, Any -from zepben.evolve import Terminal, SinglePhaseKind +from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal +from zepben.evolve.model.cim.iec61970.base.wires.single_phase_kind import SinglePhaseKind class NetworkTraceTracker: @@ -32,7 +33,7 @@ def clear(self): @staticmethod def _get_key(terminal: Terminal, phases: Set[SinglePhaseKind]) -> Any: - if phases and len(phases) < 1: - return terminal - else: + if phases: return terminal, phases + else: + return terminal diff --git a/src/zepben/evolve/services/network/tracing/networktrace/operators/__init__.py b/src/zepben/evolve/services/network/tracing/networktrace/operators/__init__.py index 4a9128eb9..81a4bae68 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/operators/__init__.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/operators/__init__.py @@ -8,4 +8,7 @@ class StateOperator(ABC): NORMAL = None - CURRENT = None \ No newline at end of file + CURRENT = None + + def __init__(self): + raise TypeError('Any class subclassing (StateOperators) should not be instantiated or have state.') diff --git a/src/zepben/evolve/services/network/tracing/networktrace/operators/equipment_container_state_operators.py b/src/zepben/evolve/services/network/tracing/networktrace/operators/equipment_container_state_operators.py index d4b1dbfe6..6431fa1f4 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/operators/equipment_container_state_operators.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/operators/equipment_container_state_operators.py @@ -15,14 +15,17 @@ if TYPE_CHECKING: from zepben.evolve.model.cim.iec61970.base.core.equipment import Equipment +__all__ = ['EquipmentContainerStateOperators', 'NormalEquipmentContainerStateOperators', 'CurrentEquipmentContainerStateOperators'] + class EquipmentContainerStateOperators(StateOperator): """ Defines operations for managing relationships between [Equipment] and [EquipmentContainer]. """ + @staticmethod @abstractmethod - def get_equipment(self, container: EquipmentContainer) -> Generator[Equipment, None, None]: + def get_equipment(container: EquipmentContainer) -> Generator[Equipment, None, None]: """ Get the collection of equipment associated with the given container. @@ -31,8 +34,9 @@ def get_equipment(self, container: EquipmentContainer) -> Generator[Equipment, N """ pass + @staticmethod @abstractmethod - def get_containers(self, equipment: Equipment) -> Generator[EquipmentContainer, None, None]: + def get_containers(equipment: Equipment) -> Generator[EquipmentContainer, None, None]: """ Retrieves a collection of containers associated with the given equipment. @@ -41,8 +45,9 @@ def get_containers(self, equipment: Equipment) -> Generator[EquipmentContainer, """ pass + @staticmethod @abstractmethod - def get_energizing_feeders(self, lv_feeder: LvFeeder) -> Generator[Feeder, None, None]: + def get_energizing_feeders(lv_feeder: LvFeeder) -> Generator[Feeder, None, None]: """ Retrieves a collection of feeders that energize the given LV feeder. @@ -51,8 +56,9 @@ def get_energizing_feeders(self, lv_feeder: LvFeeder) -> Generator[Feeder, None """ pass + @staticmethod @abstractmethod - def get_energized_lv_feeders(self, feeder: Feeder) -> Generator[LvFeeder, None, None]: + def get_energized_lv_feeders(feeder: Feeder) -> Generator[LvFeeder, None, None]: """ Retrieves a collection of LV feeders energized by the given feeder. @@ -61,8 +67,9 @@ def get_energized_lv_feeders(self, feeder: Feeder) -> Generator[LvFeeder, None, """ pass + @staticmethod @abstractmethod - def add_equipment_to_container(self, equipment: Equipment, container: EquipmentContainer): + def add_equipment_to_container(equipment: Equipment, container: EquipmentContainer): """ Adds the specified equipment to the given container. @@ -71,8 +78,9 @@ def add_equipment_to_container(self, equipment: Equipment, container: EquipmentC """ pass + @staticmethod @abstractmethod - def add_container_to_equipment(self, container: EquipmentContainer, equipment: Equipment): + def add_container_to_equipment(container: EquipmentContainer, equipment: Equipment): """ Adds the specified container to the given equipment. @@ -81,18 +89,20 @@ def add_container_to_equipment(self, container: EquipmentContainer, equipment: E """ pass - def associate_equipment_and_container(self, equipment: Equipment, container: EquipmentContainer): + @classmethod + def associate_equipment_and_container(cls, equipment: Equipment, container: EquipmentContainer): """ Establishes a bidirectional association between the specified equipment and container. `equipment` The equipment to associate with the container. `container` The container to associate with the equipment. """ - self.add_equipment_to_container(equipment, container) - self.add_container_to_equipment(container, equipment) + cls.add_equipment_to_container(equipment, container) + cls.add_container_to_equipment(container, equipment) + @staticmethod @abstractmethod - def remove_equipment_from_container(self, equipment: Equipment, container: EquipmentContainer): + def remove_equipment_from_container(equipment: Equipment, container: EquipmentContainer): """ Removes the specified equipment from the given container. @@ -101,8 +111,9 @@ def remove_equipment_from_container(self, equipment: Equipment, container: Equip """ pass + @staticmethod @abstractmethod - def remove_container_from_equipment(self, container: EquipmentContainer, equipment: Equipment): + def remove_container_from_equipment(container: EquipmentContainer, equipment: Equipment): """ Removes the specified container from the given equipment. @@ -111,18 +122,20 @@ def remove_container_from_equipment(self, container: EquipmentContainer, equipme """ pass - def disassociate_equipment_and_container(self, equipment: Equipment, container: EquipmentContainer): + @classmethod + def disassociate_equipment_and_container(cls, equipment: Equipment, container: EquipmentContainer): """ Remove a bidirectional association between the specified equipment and container. `equipment` The equipment to disassociate with the container. `container` The container to disassociate with the equipment. """ - self.remove_equipment_from_container(equipment, container) - self.remove_container_from_equipment(container, equipment) + cls.remove_equipment_from_container(equipment, container) + cls.remove_container_from_equipment(container, equipment) + @staticmethod @abstractmethod - def add_energizing_feeder_to_lv_feeder(self, feeder: Feeder, lv_feeder: LvFeeder): + def add_energizing_feeder_to_lv_feeder(feeder: Feeder, lv_feeder: LvFeeder): """ Adds the specified energizing feeder to the given lvFeeder. @@ -131,8 +144,9 @@ def add_energizing_feeder_to_lv_feeder(self, feeder: Feeder, lv_feeder: LvFeeder """ pass + @staticmethod @abstractmethod - def add_energizing_lv_feeder_to_feeder(self, lv_feeder: LvFeeder, feeder: Feeder): + def add_energizing_lv_feeder_to_feeder(lv_feeder: LvFeeder, feeder: Feeder): """ Adds the specified energized lvFeeder to the given feeder. @@ -141,49 +155,60 @@ def add_energizing_lv_feeder_to_feeder(self, lv_feeder: LvFeeder, feeder: Feeder """ pass - def associate_energizing_feeder(self, feeder: Feeder, lv_feeder: LvFeeder): + @classmethod + def associate_energizing_feeder(cls, feeder: Feeder, lv_feeder: LvFeeder): """ Establishes a bidirectional association between the specified feeder and LV feeder. `feeder` The feeder energizing the lv feeder. `lvFeeder` The lv feeder energized by the feeder. """ - self.add_energizing_feeder_to_lv_feeder(feeder, lv_feeder) - self.add_energizing_lv_feeder_to_feeder(lv_feeder, feeder) + cls.add_energizing_feeder_to_lv_feeder(feeder, lv_feeder) + cls.add_energizing_lv_feeder_to_feeder(lv_feeder, feeder) class NormalEquipmentContainerStateOperators(EquipmentContainerStateOperators): """ Operates on the normal network state equipment-container relationships """ - def get_equipment(self, container: EquipmentContainer) -> Generator[Equipment, None, None]: + @staticmethod + def get_equipment(container: EquipmentContainer) -> Generator[Equipment, None, None]: return container.equipment - def get_containers(self, equipment: Equipment) -> Generator[EquipmentContainer, None, None]: + @staticmethod + def get_containers(equipment: Equipment) -> Generator[EquipmentContainer, None, None]: return equipment.containers - def get_energizing_feeders(self, lv_feeder: LvFeeder) -> Generator[Feeder, None, None]: + @staticmethod + def get_energizing_feeders(lv_feeder: LvFeeder) -> Generator[Feeder, None, None]: return lv_feeder.normal_energizing_feeders - def get_energized_lv_feeders(self, feeder: Feeder) -> Generator[LvFeeder, None, None]: + @staticmethod + def get_energized_lv_feeders(feeder: Feeder) -> Generator[LvFeeder, None, None]: return feeder.normal_energized_lv_feeders - def add_equipment_to_container(self, equipment: Equipment, container: EquipmentContainer): + @staticmethod + def add_equipment_to_container(equipment: Equipment, container: EquipmentContainer): container.add_equipment(equipment) - def add_container_to_equipment(self, container: EquipmentContainer, equipment: Equipment): + @staticmethod + def add_container_to_equipment(container: EquipmentContainer, equipment: Equipment): equipment.add_container(container) - def remove_equipment_from_container(self, equipment: Equipment, container: EquipmentContainer): + @staticmethod + def remove_equipment_from_container(equipment: Equipment, container: EquipmentContainer): container.remove_equipment(equipment) - def remove_container_from_equipment(self, container: EquipmentContainer, equipment: Equipment): + @staticmethod + def remove_container_from_equipment(container: EquipmentContainer, equipment: Equipment): equipment.remove_container(container) - def add_energizing_feeder_to_lv_feeder(self, feeder: Feeder, lv_feeder: LvFeeder): + @staticmethod + def add_energizing_feeder_to_lv_feeder(feeder: Feeder, lv_feeder: LvFeeder): lv_feeder.add_normal_energizing_feeder(feeder) - def add_energizing_lv_feeder_to_feeder(self, lv_feeder: LvFeeder, feeder: Feeder): + @staticmethod + def add_energizing_lv_feeder_to_feeder(lv_feeder: LvFeeder, feeder: Feeder): feeder.add_normal_energized_lv_feeder(lv_feeder) @@ -191,36 +216,46 @@ class CurrentEquipmentContainerStateOperators(EquipmentContainerStateOperators): """ Operates on the current network state equipment-container relationships """ - def get_equipment(self, container: EquipmentContainer) -> Generator[Equipment, None, None]: + @staticmethod + def get_equipment(container: EquipmentContainer) -> Generator[Equipment, None, None]: return container.current_equipment - def get_containers(self, equipment: Equipment) -> Generator[EquipmentContainer, None, None]: + @staticmethod + def get_containers(equipment: Equipment) -> Generator[EquipmentContainer, None, None]: return equipment.current_containers - def get_energizing_feeders(self, lv_feeder: LvFeeder) -> Generator[Feeder, None, None]: + @staticmethod + def get_energizing_feeders(lv_feeder: LvFeeder) -> Generator[Feeder, None, None]: return lv_feeder.current_energizing_feeders - def get_energized_lv_feeders(self, feeder: Feeder) -> Generator[LvFeeder, None, None]: + @staticmethod + def get_energized_lv_feeders(feeder: Feeder) -> Generator[LvFeeder, None, None]: return feeder.current_energized_lv_feeders - def add_equipment_to_container(self, equipment: Equipment, container: EquipmentContainer): + @staticmethod + def add_equipment_to_container(equipment: Equipment, container: EquipmentContainer): container.add_current_equipment(equipment) - def add_container_to_equipment(self, container: EquipmentContainer, equipment: Equipment): + @staticmethod + def add_container_to_equipment(container: EquipmentContainer, equipment: Equipment): equipment.add_current_container(container) - def remove_equipment_from_container(self, equipment: Equipment, container: EquipmentContainer): + @staticmethod + def remove_equipment_from_container(equipment: Equipment, container: EquipmentContainer): container.remove_current_equipment(equipment) - def remove_container_from_equipment(self, container: EquipmentContainer, equipment: Equipment): + @staticmethod + def remove_container_from_equipment(container: EquipmentContainer, equipment: Equipment): equipment.remove_current_container(container) - def add_energizing_feeder_to_lv_feeder(self, feeder: Feeder, lv_feeder: LvFeeder): + @staticmethod + def add_energizing_feeder_to_lv_feeder(feeder: Feeder, lv_feeder: LvFeeder): lv_feeder.add_current_energizing_feeder(feeder) - def add_energizing_lv_feeder_to_feeder(self, lv_feeder: LvFeeder, feeder: Feeder): + @staticmethod + def add_energizing_lv_feeder_to_feeder(lv_feeder: LvFeeder, feeder: Feeder): feeder.add_current_energized_lv_feeder(lv_feeder) -EquipmentContainerStateOperators.NORMAL = NormalEquipmentContainerStateOperators() -EquipmentContainerStateOperators.CURRENT = CurrentEquipmentContainerStateOperators() +EquipmentContainerStateOperators.NORMAL = NormalEquipmentContainerStateOperators +EquipmentContainerStateOperators.CURRENT = CurrentEquipmentContainerStateOperators diff --git a/src/zepben/evolve/services/network/tracing/networktrace/operators/feeder_direction_state_operations.py b/src/zepben/evolve/services/network/tracing/networktrace/operators/feeder_direction_state_operations.py index 2e5bd3f9f..24f529516 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/operators/feeder_direction_state_operations.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/operators/feeder_direction_state_operations.py @@ -5,13 +5,13 @@ from __future__ import annotations from abc import abstractmethod -from typing import TYPE_CHECKING, Callable, TypeVar +from typing import TYPE_CHECKING, TypeVar from zepben.evolve.services.network.tracing.feeder.feeder_direction import FeederDirection if TYPE_CHECKING: from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal - from zepben.evolve.services.network.tracing.networktrace.network_trace_queue_condition import NetworkTraceQueueCondition + from zepben.evolve.services.network.tracing.networktrace.conditions.network_trace_queue_condition import NetworkTraceQueueCondition __all__ = ['FeederDirectionStateOperations', 'NormalFeederDirectionStateOperations', 'CurrentFeederDirectionStateOperations'] @@ -31,8 +31,9 @@ def get_direction(terminal: Terminal) -> FeederDirection: """ Retrieves the feeder direction for the specified terminal. - `terminal` The terminal for which to retrieve the feeder direction. - Returns The current feeder direction associated with the specified terminal. + :param terminal: The terminal for which to retrieve the feeder direction. + + :return: The current feeder direction associated with the specified terminal. """ pass @@ -42,9 +43,10 @@ def set_direction(terminal: Terminal, direction: FeederDirection) -> bool: """ Sets the feeder direction for the specified terminal. - `terminal` The terminal for which to set the feeder direction. - `direction` The new feeder direction to assign to the terminal. - Returns `true` if the direction was changed; `false` if the direction was already set to the specified value. + :param terminal: The terminal for which to set the feeder direction. + :param direction: The new feeder direction to assign to the terminal. + + :return: `True` if the direction was changed; `false` if the direction was already set to the specified value. """ pass @@ -54,9 +56,10 @@ def add_direction(terminal: Terminal, direction: FeederDirection) -> bool: """ Adds the specified feeder direction to the terminal, preserving existing directions. - `terminal` The terminal for which to add the feeder direction. - `direction` The feeder direction to add. - Returns `true` if the direction was added successfully; `false` if the direction was already present. + :param terminal: The terminal for which to add the feeder direction. + :param direction: The feeder direction to add. + + :return: `True` if the direction was added successfully; `false` if the direction was already present. """ pass @@ -67,23 +70,42 @@ def remove_direction(terminal: Terminal, direction: FeederDirection) -> bool: """ Removes the specified feeder direction from the terminal. - `terminal` The terminal for which to remove the feeder direction. - `direction` The feeder direction to remove. - Returns `true` if the direction was removed; `false` if the direction was not present. + :param terminal: The terminal for which to remove the feeder direction. + :param direction: The feeder direction to remove. + + :return: `true` if the direction was removed; `false` if the direction was not present. """ pass @classmethod def upstream(cls) -> NetworkTraceQueueCondition[T]: - return cls.with_direction(FeederDirection.UPSTREAM, cls.get_direction) + """ + Creates a [NetworkTrace] condition that will cause tracing a feeder upstream (towards the head terminal). + This uses [FeederDirectionStateOperations.get_direction] receiver instance method within the condition. + + :return: [NetworkTraceQueueCondition] that results in upstream tracing. + """ + return cls.with_direction(FeederDirection.UPSTREAM) @classmethod def downstream(cls) -> NetworkTraceQueueCondition[T]: - return cls.with_direction(FeederDirection.DOWNSTREAM, cls.get_direction) + """ + Creates a [NetworkTrace] condition that will cause tracing a feeder downstream (away from the head terminal). + This uses [FeederDirectionStateOperations.get_direction] receiver instance method within the condition. - @staticmethod - def with_direction(direction: FeederDirection, get_direction: Callable[[Terminal], FeederDirection]) -> NetworkTraceQueueCondition[T]: - return DirectionCondition(direction, get_direction) + :return: [NetworkTraceQueueCondition] that results in downstream tracing. + """ + return cls.with_direction(FeederDirection.DOWNSTREAM) + + @classmethod + def with_direction(cls, direction: FeederDirection) -> NetworkTraceQueueCondition[T]: + """ + Creates a [NetworkTrace] condition that will cause tracing only terminals with directions that match [direction]. + This uses [FeederDirectionStateOperations.get_direction] receiver instance method within the condition. + + :return: [NetworkTraceQueueCondition] that results in upstream tracing. + """ + return DirectionCondition(direction, cls) class NormalFeederDirectionStateOperations(FeederDirectionStateOperations): @staticmethod @@ -152,5 +174,5 @@ def remove_direction(terminal: Terminal, direction: FeederDirection) -> bool: terminal.current_feeder_direction = new return True -FeederDirectionStateOperations.NORMAL = NormalFeederDirectionStateOperations() -FeederDirectionStateOperations.CURRENT = CurrentFeederDirectionStateOperations() +FeederDirectionStateOperations.NORMAL = NormalFeederDirectionStateOperations +FeederDirectionStateOperations.CURRENT = CurrentFeederDirectionStateOperations diff --git a/src/zepben/evolve/services/network/tracing/networktrace/operators/in_service_state_operators.py b/src/zepben/evolve/services/network/tracing/networktrace/operators/in_service_state_operators.py index d17cfa59b..aca882132 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/operators/in_service_state_operators.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/operators/in_service_state_operators.py @@ -14,6 +14,8 @@ if TYPE_CHECKING: from zepben.evolve.model.cim.iec61970.base.core.equipment import Equipment +__all__ = ['InServiceStateOperators', 'NormalInServiceStateOperators', 'CurrentInServiceStateOperators'] + class InServiceStateOperators(StateOperator): """ @@ -52,7 +54,7 @@ def is_in_service(equipment: Equipment): return equipment.normally_in_service @staticmethod - def set_in_service(equipment: Equipment, in_service: bool) -> bool: + def set_in_service(equipment: Equipment, in_service: bool) -> None: equipment.normally_in_service = in_service @@ -65,8 +67,8 @@ def is_in_service(equipment: Equipment): return equipment.in_service @staticmethod - def set_in_service(equipment: Equipment, in_service: bool) -> bool: + def set_in_service(equipment: Equipment, in_service: bool) -> None: equipment.in_service = in_service -InServiceStateOperators.NORMAL = NormalInServiceStateOperators() -InServiceStateOperators.CURRENT = CurrentInServiceStateOperators() \ No newline at end of file +InServiceStateOperators.NORMAL = NormalInServiceStateOperators +InServiceStateOperators.CURRENT = CurrentInServiceStateOperators diff --git a/src/zepben/evolve/services/network/tracing/networktrace/operators/network_state_operators.py b/src/zepben/evolve/services/network/tracing/networktrace/operators/network_state_operators.py index d8ea15d46..80aebb851 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/operators/network_state_operators.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/operators/network_state_operators.py @@ -3,16 +3,36 @@ # 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/. -from abc import ABC +from __future__ import annotations -from zepben.evolve.services.network.tracing.networktrace.operators.equipment_container_state_operators import EquipmentContainerStateOperators -from zepben.evolve.services.network.tracing.networktrace.operators.feeder_direction_state_operations import FeederDirectionStateOperations -from zepben.evolve.services.network.tracing.networktrace.operators.in_service_state_operators import InServiceStateOperators -from zepben.evolve.services.network.tracing.networktrace.operators.open_state_operators import OpenStateOperators -from zepben.evolve.services.network.tracing.networktrace.operators.phase_state_operators import PhaseStateOperators +from abc import abstractmethod +from functools import lru_cache +from typing import Type, Generator, TYPE_CHECKING +from zepben.evolve.util import classproperty +from zepben.evolve.services.network.tracing.networktrace.network_trace_step_path_provider import NetworkTraceStepPathProvider +from zepben.evolve.services.network.tracing.networktrace.operators.equipment_container_state_operators import EquipmentContainerStateOperators, \ + NormalEquipmentContainerStateOperators, CurrentEquipmentContainerStateOperators +from zepben.evolve.services.network.tracing.networktrace.operators.feeder_direction_state_operations import FeederDirectionStateOperations, \ + NormalFeederDirectionStateOperations, CurrentFeederDirectionStateOperations +from zepben.evolve.services.network.tracing.networktrace.operators.in_service_state_operators import InServiceStateOperators, NormalInServiceStateOperators, \ + CurrentInServiceStateOperators +from zepben.evolve.services.network.tracing.networktrace.operators.open_state_operators import OpenStateOperators, NormalOpenStateOperators, \ + CurrentOpenStateOperators +from zepben.evolve.services.network.tracing.networktrace.operators.phase_state_operators import PhaseStateOperators, NormalPhaseStateOperators, \ + CurrentPhaseStateOperators -class NetworkStateOperators(ABC): +if TYPE_CHECKING: + from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep + +__all__ = ['NetworkStateOperators', 'NormalNetworkStateOperators', 'CurrentNetworkStateOperators'] + +# noinspection PyPep8Naming +class NetworkStateOperators(OpenStateOperators, + FeederDirectionStateOperations, + EquipmentContainerStateOperators, + InServiceStateOperators, + PhaseStateOperators): """ Interface providing access to and operations on specific network state properties and functions for items within a network. This interface consolidates several other state operator interfaces, enabling unified management of operations for a network state. @@ -26,56 +46,62 @@ class NetworkStateOperators(ABC): By using this interface, you can apply identical conditions and steps without needing to track which state is active or creating redundant trace implementations for different network states. """ - _operators = [] - def __getattribute__(self, item): - """ - This allows NetworkStateOperators to implement the functions (and accidentally, the attributes) of any class in _operators - if its not present in this object + @classproperty + def NORMAL(cls) -> Type['NormalNetworkStateOperators']: + return NormalNetworkStateOperators - TODO: this is functional, but not optimal and can be made smarter and faster. - """ - try: - return super().__getattribute__(item) - except AttributeError as e: - for operator in self._operators: - if hasattr(operator, item): - return operator.__getattribute__(item) - raise e + @classproperty + def CURRENT(cls) -> Type['CurrentNetworkStateOperators']: + return CurrentNetworkStateOperators + @classmethod + @abstractmethod + def next_paths(cls, path: NetworkTraceStep.Path) -> Generator[NetworkTraceStep.Path, None, None]: + pass -class NormalNetworkStateOperators(NetworkStateOperators): + +class NormalNetworkStateOperators(NetworkStateOperators, + NormalOpenStateOperators, + NormalFeederDirectionStateOperations, + NormalEquipmentContainerStateOperators, + NormalInServiceStateOperators, + NormalPhaseStateOperators): """ Instance that operates on the normal state of network objects. """ - _operators = [ - OpenStateOperators.NORMAL, - FeederDirectionStateOperations.NORMAL, - EquipmentContainerStateOperators.NORMAL, - InServiceStateOperators.NORMAL, - PhaseStateOperators.NORMAL - ] - - @staticmethod - def condition(): - return NetworkStateOperators.NORMAL - -class CurrentNetworkStateOperators(NetworkStateOperators): + + CURRENT = False + NORMAL = True + + @classmethod + @lru_cache + def network_trace_step_path_provider(cls): + return NetworkTraceStepPathProvider(cls) + + @classmethod + def next_paths(cls, path: NetworkTraceStep.Path) -> Generator[NetworkTraceStep.Path, None, None]: + yield from cls.network_trace_step_path_provider().next_paths(path) + +class CurrentNetworkStateOperators(NetworkStateOperators, + CurrentOpenStateOperators, + CurrentFeederDirectionStateOperations, + CurrentEquipmentContainerStateOperators, + CurrentInServiceStateOperators, + CurrentPhaseStateOperators): """ Instance that operates on the current state of network objects. """ - _operators = [ - OpenStateOperators.CURRENT, - FeederDirectionStateOperations.CURRENT, - EquipmentContainerStateOperators.CURRENT, - InServiceStateOperators.CURRENT, - PhaseStateOperators.CURRENT, - ] - @staticmethod - def condition(): - return NetworkStateOperators.CURRENT + CURRENT = True + NORMAL = False + + @classmethod + @lru_cache + def network_trace_step_path_provider(cls): + return NetworkTraceStepPathProvider(cls) + @classmethod + def next_paths(cls, path: NetworkTraceStep.Path) -> Generator[NetworkTraceStep.Path, None, None]: + yield from cls.network_trace_step_path_provider().next_paths(path) -NetworkStateOperators.NORMAL = NormalNetworkStateOperators() -NetworkStateOperators.CURRENT = CurrentNetworkStateOperators() diff --git a/src/zepben/evolve/services/network/tracing/networktrace/operators/open_state_operators.py b/src/zepben/evolve/services/network/tracing/networktrace/operators/open_state_operators.py index 693851500..8be1b4198 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/operators/open_state_operators.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/operators/open_state_operators.py @@ -4,14 +4,13 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from __future__ import annotations -from typing import TypeVar, Optional, TYPE_CHECKING - -from zepben.evolve.model.cim.iec61970.base.wires.single_phase_kind import SinglePhaseKind +from typing import TypeVar, Optional, TYPE_CHECKING, Callable from abc import abstractmethod +from zepben.evolve.model.cim.iec61970.base.wires.single_phase_kind import SinglePhaseKind from zepben.evolve.services.network.tracing.networktrace.conditions.open_condition import OpenCondition -from zepben.evolve.services.network.tracing.networktrace.network_trace_queue_condition import NetworkTraceQueueCondition +from zepben.evolve.services.network.tracing.networktrace.conditions.network_trace_queue_condition import NetworkTraceQueueCondition from zepben.evolve.services.network.tracing.networktrace.operators import StateOperator if TYPE_CHECKING: @@ -20,6 +19,8 @@ T = TypeVar('T') +__all__ = ['OpenStateOperators', 'NormalOpenStateOperators', 'CurrentOpenStateOperators'] + class OpenStateOperators(StateOperator): """ @@ -66,8 +67,8 @@ def set_open(switch: Switch, is_open: bool, phase: SinglePhaseKind=None) -> None raise NotImplementedError() @classmethod - def stop_at_open(cls) -> NetworkTraceQueueCondition[T]: - return OpenCondition(cls.is_open) + def stop_at_open(cls, open_test: Optional[Callable[[Switch, Optional[SinglePhaseKind]], bool]]=None, phase: Optional[SinglePhaseKind]=None) -> NetworkTraceQueueCondition[T]: + return OpenCondition(open_test or cls.is_open, phase) class NormalOpenStateOperators(OpenStateOperators): @@ -96,5 +97,5 @@ def set_open(switch: Switch, is_open: bool, phase: SinglePhaseKind = None) -> No switch.set_open(is_open, phase) -OpenStateOperators.NORMAL = NormalOpenStateOperators() -OpenStateOperators.CURRENT = CurrentOpenStateOperators() +OpenStateOperators.NORMAL = NormalOpenStateOperators +OpenStateOperators.CURRENT = CurrentOpenStateOperators diff --git a/src/zepben/evolve/services/network/tracing/networktrace/operators/phase_state_operators.py b/src/zepben/evolve/services/network/tracing/networktrace/operators/phase_state_operators.py index a9989b15b..be511c2b6 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/operators/phase_state_operators.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/operators/phase_state_operators.py @@ -12,14 +12,17 @@ if TYPE_CHECKING: from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal +__all__ = ['PhaseStateOperators', 'NormalPhaseStateOperators', 'CurrentPhaseStateOperators'] + class PhaseStateOperators(StateOperator): """ Interface for accessing the phase status of a terminal. """ + @staticmethod @abstractmethod - def phase_status(self, terminal: 'Terminal') -> PhaseStatus: + def phase_status(terminal: 'Terminal') -> PhaseStatus: """ Retrieves the phase status of the specified terminal. @@ -33,7 +36,8 @@ class NormalPhaseStateOperators(PhaseStateOperators): """ Operates on the normal state of the `Phase` """ - def phase_status(self, terminal: 'Terminal') -> PhaseStatus: + @staticmethod + def phase_status(terminal: 'Terminal') -> PhaseStatus: return terminal.normal_phases @@ -41,9 +45,10 @@ class CurrentPhaseStateOperators(PhaseStateOperators): """ Operates on the current state of the `Phase` """ - def phase_status(self, terminal: 'Terminal') -> PhaseStatus: + @staticmethod + def phase_status(terminal: 'Terminal') -> PhaseStatus: return terminal.current_phases -PhaseStateOperators.NORMAL = NormalPhaseStateOperators() -PhaseStateOperators.CURRENT = CurrentPhaseStateOperators() \ No newline at end of file +PhaseStateOperators.NORMAL = NormalPhaseStateOperators +PhaseStateOperators.CURRENT = CurrentPhaseStateOperators diff --git a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py index 37a08009e..1dd794dd3 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py @@ -2,7 +2,7 @@ # 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/. -from typing import TypeVar, Union, Callable +from typing import TypeVar, Union, Callable, Type from zepben.evolve.services.network.tracing.networktrace.compute_data import ComputeData, ComputeDataWithPaths from zepben.evolve.services.network.tracing.networktrace.network_trace import NetworkTrace @@ -16,7 +16,7 @@ class Tracing: @staticmethod - def network_trace(network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL, + def network_trace(network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, action_step_type: NetworkTraceActionType=NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, queue: TraversalQueue[NetworkTraceStep[T]]=TraversalQueue.depth_first(), compute_data: Union[ComputeData[T], Callable]=None @@ -37,14 +37,13 @@ def network_trace(network_state_operators: NetworkStateOperators=NetworkStateOpe return NetworkTrace.non_branching(network_state_operators, queue, action_step_type, compute_data) @staticmethod - def network_trace_branching(network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL, + def network_trace_branching(network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, action_step_type: NetworkTraceActionType=NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, queue_factory: Callable[[], TraversalQueue[NetworkTraceStep[T]]]=lambda: TraversalQueue.depth_first(), branch_queue_factory: Callable[[], TraversalQueue[NetworkTrace[NetworkTraceStep[T]]]]=lambda: TraversalQueue.breadth_first(), compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]]=None ) -> NetworkTrace[T]: - if not isinstance(compute_data, ComputeData): compute_data = ComputeData(compute_data or (lambda *args: None)) diff --git a/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py b/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py index e9c4bb61a..955daaf07 100644 --- a/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py +++ b/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py @@ -4,7 +4,7 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. import logging from dataclasses import dataclass -from typing import Dict, Callable, List, Set, Awaitable +from typing import Dict, Callable, List, Set, Awaitable, Type from zepben.evolve import Terminal, SinglePhaseKind, ConductingEquipment, NetworkService, \ FeederDirection, X_PRIORITY, Y_PRIORITY, is_before, is_after @@ -13,8 +13,6 @@ __all__ = ["PhaseInferrer"] -logger = logging.getLogger(__name__) - class PhaseInferrer: """ @@ -26,17 +24,17 @@ class InferredPhase: def __init__(self, conducting_equipment: ConductingEquipment, suspect: bool): self.conducting_equipment = conducting_equipment self.suspect = suspect - logger.warning(f'*** Action Required *** Inferred missing {self.description} due to a disconnected nominal phase because of an ' - f'upstream error in the source data. Phasing information for the upstream equipment should be fixed in the source system.') @property def description(self) -> str: if self.suspect: - return f"phases for '{self.conducting_equipment.name}' [{self.conducting_equipment.mrid}] which may not be correct. The phases were inferred" + _inner_desc = f"phases for '{self.conducting_equipment.name}' [{self.conducting_equipment.mrid}] which may not be correct. The phases were inferred" else: - return f"phase for '{self.conducting_equipment.name}' [{self.conducting_equipment.mrid}] which should be correct. The phase was inferred" + _inner_desc = f"phase for '{self.conducting_equipment.name}' [{self.conducting_equipment.mrid}] which should be correct. The phase was inferred" + return (f'Inferred missing {_inner_desc} due to a disconnected nominal phase because of an ' + f'upstream error in the source data. Phasing information for the upstream equipment should be fixed in the source system.') - async def run(self, network: NetworkService, network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL) -> list[InferredPhase]: + async def run(self, network: NetworkService, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL) -> list[InferredPhase]: """ Infer the missing phases on the specified `network`. @@ -51,7 +49,7 @@ async def run(self, network: NetworkService, network_state_operators: NetworkSta class PhaseInferrerInternal: - def __init__(self, state_operators: NetworkStateOperators): + def __init__(self, state_operators: Type[NetworkStateOperators]): self.state_operators = state_operators async def infer_missing_phases(self, network: NetworkService, tracking: Dict[ConductingEquipment, bool]): @@ -59,9 +57,9 @@ async def infer_missing_phases(self, network: NetworkService, tracking: Dict[Con terms_missing_phases = [it for it in network.objects(Terminal) if self._is_connected_to_others(it) and self._has_none_phase(it)] terms_missing_xy_phases = [it for it in terms_missing_phases if self._has_xy_phases(it)] - if not (await self._process(terms_missing_phases, lambda t: self._set_missing_to_nominal(t, tracking)) or - await self._process(terms_missing_xy_phases, lambda t: self._infer_xy_phases(t, 1, tracking)) or - await self._process(terms_missing_xy_phases, lambda t: self._infer_xy_phases(t, 4, tracking)) + if not (await self._process(terms_missing_phases, lambda t: self._set_missing_to_nominal(t, tracking)) + or await self._process(terms_missing_xy_phases, lambda t: self._infer_xy_phases(t, 1, tracking)) + or await self._process(terms_missing_xy_phases, lambda t: self._infer_xy_phases(t, 4, tracking)) ): break @@ -90,8 +88,8 @@ def _find_terminal_at_start_of_missing_phases( def _missing_from_down_to_up(self, terminals: List[Terminal]) -> List[Terminal]: return [ terminal for terminal in terminals - if (self._missing_from_down_filter(terminal) and - (FeederDirection.UPSTREAM in self.state_operators.get_direction(terminal))) + if (FeederDirection.UPSTREAM in self.state_operators.get_direction(terminal)) + and self._missing_from_down_filter(terminal) ] def _missing_from_down_to_any(self, terminals: List[Terminal]) -> List[Terminal]: @@ -188,7 +186,7 @@ async def _infer_xy_phases(self, terminal: Terminal, max_missing_phases: int, tr async def _continue_phases(self, terminal: Terminal): set_phases_trace = Tracing.set_phases() for other in terminal.other_terminals(): - await set_phases_trace.spread_phases(terminal, other, terminal.phases.single_phases, network_state_operators=self.state_operators) + await set_phases_trace.run_spread_phases_and_flow(terminal, other, terminal.phases.single_phases, network_state_operators=self.state_operators) @staticmethod def _first_unused(phases: List[SinglePhaseKind], used_phases: Set[SinglePhaseKind], validate: Callable[[SinglePhaseKind], bool]) -> SinglePhaseKind: diff --git a/src/zepben/evolve/services/network/tracing/phases/remove_phases.py b/src/zepben/evolve/services/network/tracing/phases/remove_phases.py index b4d4626a3..7c7850d1d 100644 --- a/src/zepben/evolve/services/network/tracing/phases/remove_phases.py +++ b/src/zepben/evolve/services/network/tracing/phases/remove_phases.py @@ -5,20 +5,18 @@ from __future__ import annotations -from typing import Set, Union +from typing import Set, Union, Type from zepben.evolve import NetworkService from zepben.evolve.model.cim.iec61970.base.core.phase_code import PhaseCode from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal from zepben.evolve.model.cim.iec61970.base.wires.single_phase_kind import SinglePhaseKind -from zepben.evolve.services.network.tracing.networktrace.compute_data import ComputeData from zepben.evolve.services.network.tracing.networktrace.network_trace import NetworkTrace from zepben.evolve.services.network.tracing.networktrace.network_trace_action_type import NetworkTraceActionType from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing from zepben.evolve.services.network.tracing.traversal.step_context import StepContext -from zepben.evolve.services.network.tracing.traversal.traversal import Traversal from zepben.evolve.services.network.tracing.traversal.weighted_priority_queue import WeightedPriorityQueue @@ -37,7 +35,7 @@ class RemovePhases(object): async def run(self, start: Union[NetworkService, Terminal], nominal_phases_to_ebb: Union[PhaseCode, SinglePhaseKind]=None, - network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL): + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): if nominal_phases_to_ebb is None: if isinstance(start, NetworkService): @@ -49,17 +47,17 @@ async def run(self, return await self._run_with_phases_to_ebb(start, nominal_phases_to_ebb, network_state_operators) @staticmethod - async def _run_with_network(network_service: NetworkService, network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL): + async def _run_with_network(network_service: NetworkService, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): for t in network_service.objects(Terminal): t.traced_phases.phase_status = 0 - async def _run_with_terminal(self, terminal: Terminal, network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL): + async def _run_with_terminal(self, terminal: Terminal, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): return await self._run_with_phases_to_ebb(terminal, terminal.phases, network_state_operators) async def _run_with_phases_to_ebb(self, terminal: Terminal, nominal_phases_to_ebb: Union[PhaseCode, Set[SinglePhaseKind]], - network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL): + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): if isinstance(nominal_phases_to_ebb, PhaseCode): return await self._run_with_phases_to_ebb(terminal, set(nominal_phases_to_ebb.single_phases), network_state_operators) @@ -67,7 +65,7 @@ async def _run_with_phases_to_ebb(self, trace = await self._create_trace(network_state_operators) return await trace.run(terminal, EbbPhases(nominal_phases_to_ebb), terminal.phases) - async def _create_trace(self, state_operators: NetworkStateOperators) -> NetworkTrace[EbbPhases]: + async def _create_trace(self, state_operators: Type[NetworkStateOperators]) -> NetworkTrace[EbbPhases]: def compute_data(step: NetworkTraceStep[EbbPhases], context: StepContext, next_path: NetworkTraceStep.Path): data = [] @@ -92,7 +90,7 @@ def queue_condition(next_step: NetworkTraceStep, next_ctx: StepContext=None, ste .add_queue_condition(queue_condition) @staticmethod - async def _ebb(state_operators: NetworkStateOperators, terminal: Terminal, phases_to_ebb: Set[SinglePhaseKind]) -> Set[SinglePhaseKind]: + async def _ebb(state_operators: Type[NetworkStateOperators], terminal: Terminal, phases_to_ebb: Set[SinglePhaseKind]) -> Set[SinglePhaseKind]: phases = state_operators.phase_status(terminal) for phase in phases_to_ebb: if phases[phase] != SinglePhaseKind.NONE: diff --git a/src/zepben/evolve/services/network/tracing/phases/set_phases.py b/src/zepben/evolve/services/network/tracing/phases/set_phases.py index 7bc2c4da1..4876b68a8 100644 --- a/src/zepben/evolve/services/network/tracing/phases/set_phases.py +++ b/src/zepben/evolve/services/network/tracing/phases/set_phases.py @@ -6,7 +6,7 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Union, Set, Iterable, List +from typing import Union, Set, Iterable, List, Type from zepben.evolve.exceptions import TracingException, PhaseException from zepben.evolve.model.cim.iec61970.base.core.phase_code import PhaseCode @@ -22,7 +22,6 @@ from zepben.evolve.services.network.tracing.networktrace.network_trace_action_type import NetworkTraceActionType from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing -from zepben.evolve.services.network.tracing.traversal.traversal import Traversal from zepben.evolve.services.network.tracing.traversal.weighted_priority_queue import WeightedPriorityQueue __all__ = ["SetPhases"] @@ -43,7 +42,7 @@ def __init__(self, nominal_phase_paths: Iterable[NominalPhasePath], step_flowed_ async def run(self, apply_to: Union[NetworkService, Terminal], phases: Union[PhaseCode, Iterable[SinglePhaseKind]]=None, - network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL): + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): if isinstance(apply_to, NetworkService): return await self._run(apply_to, network_state_operators) @@ -59,7 +58,7 @@ async def run(self, async def _run(self, network: NetworkService, - network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL): + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): """ Apply phases from all sources in the network. @@ -74,7 +73,7 @@ async def _run(self, async def _run_with_phases(self, terminal: Terminal, phases: Union[PhaseCode, Iterable[SinglePhaseKind]], - network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL): + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): """ Apply phases from the `terminal`. @@ -100,14 +99,14 @@ def validate_phases(_phases): await self._run_terminal(terminal, network_state_operators) - async def _run_spread_phases_and_flow(self, - seed_terminal: Terminal, - start_terminal: Terminal, - phases: List[SinglePhaseKind], - network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL): + async def run_spread_phases_and_flow(self, + seed_terminal: Terminal, + start_terminal: Terminal, + phases: List[SinglePhaseKind], + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): nominal_phase_paths = self._get_nominal_phase_paths(network_state_operators, seed_terminal, start_terminal, list(phases)) - if self._flow_phases(network_state_operators, seed_terminal, start_terminal, nominal_phase_paths): + if await self._flow_phases(network_state_operators, seed_terminal, start_terminal, nominal_phase_paths): await self.run(start_terminal, network_state_operators=network_state_operators) @@ -116,7 +115,7 @@ async def spread_phases( from_terminal: Terminal, to_terminal: Terminal, phases: List[SinglePhaseKind]=None, - network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL ): """ Apply phases from the `from_terminal` to the `to_terminal`. @@ -133,14 +132,14 @@ async def spread_phases( if await self._flow_phases(network_state_operators, from_terminal, to_terminal, paths): await self.run(from_terminal, network_state_operators=network_state_operators) - async def _run_terminal(self, terminal: Terminal, network_state_operators: NetworkStateOperators, trace: NetworkTrace[PhasesToFlow]=None): + async def _run_terminal(self, terminal: Terminal, network_state_operators: Type[NetworkStateOperators], trace: NetworkTrace[PhasesToFlow]=None): if trace is None: trace = await self._create_network_trace(network_state_operators) nominal_phase_paths = list(map(lambda it: NominalPhasePath(SinglePhaseKind.NONE, it), terminal.phases)) await trace.run(terminal, self.PhasesToFlow(nominal_phase_paths), can_stop_on_start_item=False) trace.reset() - async def _create_network_trace(self, state_operators: NetworkStateOperators) -> NetworkTrace[PhasesToFlow]: + async def _create_network_trace(self, state_operators: Type[NetworkStateOperators]) -> NetworkTrace[PhasesToFlow]: async def step_action(nts, ctx): path = nts.path phases_to_flow = nts.data @@ -166,7 +165,7 @@ def _get_weight(it) -> int: .add_step_action(step_action) ) - def _compute_next_phases_to_flow(self, state_operators: NetworkStateOperators) -> ComputeData[PhasesToFlow]: + def _compute_next_phases_to_flow(self, state_operators: Type[NetworkStateOperators]) -> ComputeData[PhasesToFlow]: def inner(step, _, next_path): if not step.data.step_flowed_phases: return self.PhasesToFlow([]) @@ -177,7 +176,7 @@ def inner(step, _, next_path): return ComputeData(inner) @staticmethod - def _apply_phases(state_operators: NetworkStateOperators, + def _apply_phases(state_operators: Type[NetworkStateOperators], terminal: Terminal, phases: List[SinglePhaseKind]): @@ -185,7 +184,7 @@ def _apply_phases(state_operators: NetworkStateOperators, for i, nominal_phase in enumerate(terminal.phases.single_phases): traced_phases[nominal_phase] = phases[i] if phases[i] not in PhaseCode.XY else SinglePhaseKind.NONE - def _get_nominal_phase_paths(self, state_operators: NetworkStateOperators, + def _get_nominal_phase_paths(self, state_operators: Type[NetworkStateOperators], from_terminal: Terminal, to_terminal: Terminal, phases: Sequence[SinglePhaseKind] @@ -199,7 +198,7 @@ def _get_nominal_phase_paths(self, state_operators: NetworkStateOperators, return TerminalConnectivityConnected().terminal_connectivity(from_terminal, to_terminal, phases_to_flow).nominal_phase_paths @staticmethod - async def _flow_phases(state_operators: NetworkStateOperators, + async def _flow_phases(state_operators: Type[NetworkStateOperators], from_terminal: Terminal, to_terminal: Terminal, nominal_phase_paths: Iterable[NominalPhasePath] @@ -250,7 +249,7 @@ def get_ce_details(terminal: Terminal): @staticmethod def _get_phases_to_flow( - state_operators: NetworkStateOperators, + state_operators: Type[NetworkStateOperators], terminal: Terminal, phases: Sequence[SinglePhaseKind], internal_flow: bool diff --git a/src/zepben/evolve/services/network/tracing/traversal/context_value_computer.py b/src/zepben/evolve/services/network/tracing/traversal/context_value_computer.py index de2f0e426..514e4cd36 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/context_value_computer.py +++ b/src/zepben/evolve/services/network/tracing/traversal/context_value_computer.py @@ -3,7 +3,7 @@ # 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/. -from abc import ABC +from abc import abstractmethod from typing import TypeVar, Generic from zepben.evolve.services.network.tracing.traversal.step_context import StepContext @@ -11,8 +11,10 @@ T = TypeVar('T') U = TypeVar('U') +__all__ = ['ContextValueComputer', 'TypedContextValueComputer'] -class ContextValueComputer(ABC, Generic[T]): + +class ContextValueComputer(Generic[T]): """ Interface representing a context value computer used to compute and store values in a [StepContext]. This interface does not specify a generic return type because the [StepContext] stores its values as `Any?`. @@ -23,6 +25,7 @@ class ContextValueComputer(ABC, Generic[T]): def __init__(self, key: str): self.key = key # A unique key identifying the context value computed by this computer. + @abstractmethod def compute_initial_value(self, item: T): """ Computes the initial context value for the given starting item. @@ -32,6 +35,7 @@ def compute_initial_value(self, item: T): """ pass + @abstractmethod def compute_next_value(self, next_item: T, current_item: T, current_value): """ Computes the next context value based on the current item, next item, and the current context value. @@ -44,7 +48,7 @@ def compute_next_value(self, next_item: T, current_item: T, current_value): pass def is_standalone_computer(self): - return all(not isinstance(self, o) for o in (StepAction, StopCondition, QueueCondition)) + return not isinstance(self, (StepAction, StopCondition, QueueCondition)) class TypedContextValueComputer(ContextValueComputer, Generic[T, U]): """ diff --git a/src/zepben/evolve/services/network/tracing/traversal/queue.py b/src/zepben/evolve/services/network/tracing/traversal/queue.py index 621de38ed..d7bc0cb05 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/queue.py +++ b/src/zepben/evolve/services/network/tracing/traversal/queue.py @@ -8,166 +8,110 @@ # 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/. - from __future__ import annotations +from abc import abstractmethod, ABCMeta from collections import deque -from typing import TypeVar, Iterable, Generic -from heapq import heappush, heappop - -__all__ = ["FifoQueue", "LifoQueue", "PriorityQueue", "TraversalQueue"] +from typing import TypeVar, Iterable, Generic, Deque, TYPE_CHECKING, Union T = TypeVar('T') U = TypeVar('U') +__all__ = ["TraversalQueue"] + + +class FIFODeque(deque): + def pop(self): + return self.popleft() + + def peek(self) -> T: + return self[-1] + + +class LIFODeque(deque): + def peek(self) -> T: + return self[0] -# TODO: the methods in these classes overlap in a slightly unclear way, this needs to be tidied up. -class TraversalQueue(Generic[T]): +class TraversalQueue(Generic[T], metaclass=ABCMeta): """ Basic queue object, implementing some methods to align it with the kotlin sdk syntax, """ - def __init__(self, queue=None): - if queue is None: - self.queue = deque() - else: - self.queue = queue - - def __iter__(self): - return self.queue.__iter__() - + @abstractmethod def __len__(self): - return len(self.queue) + """:return: the length of the queue""" @classmethod - def breadth_first(cls) -> TraversalQueue: + def breadth_first(cls) -> TraversalQueue[T]: """ Creates a new instance backed by a breadth first (FIFO) queue. """ - return cls(FifoQueue()) + return BasicQueue(FIFODeque()) @classmethod - def depth_first(cls) -> TraversalQueue: + def depth_first(cls) -> TraversalQueue[T]: """ Creates a new instance backed by a depth first (LIFO) queue. """ - return cls(LifoQueue()) + return BasicQueue(LIFODeque()) + @abstractmethod def has_next(self) -> bool: - """ :return: True if the queue has more items. """ - return len(self.queue) > 0 + """:return: True if the queue has more items.""" + @abstractmethod def pop(self): - return self.queue.pop() + """:return: The next item in the queue""" - def put(self, item: T) -> bool: - self.queue.put(item) - return True - - def extend(self, items: Iterable[T]) -> bool: - return self.queue.extend(items) - - def clear(self): - return self.queue.clear() - - -class FifoQueue(TraversalQueue[T]): - """Used for Breadth-first Traversal's""" - - def put(self, item: T): - return self.queue.append(item) - - def extend(self, items: Iterable[T]): - return self.queue.extend(items) - - def pop(self) -> T: + @abstractmethod + def append(self, item: T) -> bool: """ - Pop an item off the queue. - Raises `IndexError` if the queue is empty. - """ - return self.queue.popleft() - - def empty(self) -> bool: - """ - Check if queue is empty - Returns True if empty, False otherwise - """ - return len(self.queue) == 0 - - def clear(self): - """Clear the queue.""" - self.queue.clear() - - def copy(self) -> FifoQueue[T]: - return FifoQueue(self.queue.copy()) - + Adds an item to the queue -class LifoQueue(TraversalQueue[T]): - """Used for Depth-first Traversal's""" + :param item: The item to be added to the queue - def put(self, item: T): - self.queue.append(item) + :return: True if the item was added + """ + @abstractmethod def extend(self, items: Iterable[T]): - self.queue.extend(items) - - def pop(self) -> T: """ - Pop an item off the queue. - Raises `IndexError` if the queue is empty. - """ - return self.queue.pop() + Adds the items to the queue - def empty(self) -> bool: + :param items: The items to be added to the queue """ - Check if queue is empty - Returns True if empty, False otherwise - """ - return len(self.queue) == 0 + @abstractmethod + def peek(self) -> T: + """:return: The next item on the queue without removing it""" + + @abstractmethod def clear(self): - """Clear the queue.""" - self.queue.clear() + """Clears the queue""" - def copy(self) -> LifoQueue[T]: - return LifoQueue(self.queue.copy()) +class BasicQueue(TraversalQueue, Generic[T]): -class PriorityQueue(TraversalQueue[T]): - """Used for custom `Traversal`s""" + def __init__(self, queue: Union[FIFODeque, LIFODeque]): + self.queue = queue - def __init__(self, queue=None): - if queue is None: - super().__init__([]) - else: - super().__init__(queue) + def __iter__(self): + return self.queue.__iter__() def __len__(self): return len(self.queue) - def put(self, item: T): - """ - Place an item in the queue based on its priority. - `item` The item to place on the queue. Must implement `__lt__` - Returns True if put was successful, False otherwise. - """ - heappush(self.queue, item) + def has_next(self) -> bool: + return len(self.queue) > 0 - def extend(self, items: Iterable[T]): - for item in items: - heappush(self.queue, item) + def pop(self): + return self.queue.pop() - def pop(self) -> T: - """ - Get the next item in the queue, removing it from the queue. - Returns The next item in the queue by priority. - Raises `IndexError` if the queue is empty - """ - return heappop(self.queue) + def append(self, item: T) -> bool: + self.queue.append(item) + return True - def empty(self) -> bool: - return len(self) == 0 + def extend(self, items: Iterable[T]) -> None: + self.queue.extend(items) + + def peek(self) -> T: + return self.queue.peek() def clear(self): - """Clear the queue.""" self.queue.clear() - - def copy(self) -> PriorityQueue[T]: - return PriorityQueue(self.queue.copy()) - diff --git a/src/zepben/evolve/services/network/tracing/traversal/queue_condition.py b/src/zepben/evolve/services/network/tracing/traversal/queue_condition.py index f6695c9f7..d60b4723d 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/queue_condition.py +++ b/src/zepben/evolve/services/network/tracing/traversal/queue_condition.py @@ -4,7 +4,8 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from __future__ import annotations -from typing import TypeVar, Generic +from abc import abstractmethod +from typing import TypeVar, Generic, Callable from zepben.evolve.services.network.tracing.traversal.step_context import StepContext from zepben.evolve.services.network.tracing.traversal.traversal_condition import TraversalCondition @@ -12,14 +13,22 @@ T = TypeVar('T') U = TypeVar('U') +ShouldQueue = Callable[[T, StepContext, T, StepContext], bool] +ShouldQueueStartItem = Callable[[T], bool] -class QueueCondition(TraversalCondition[T], Generic[T]): +__all__ = ['QueueCondition', 'QueueConditionWithContextValue', 'ShouldQueue', 'ShouldQueueStartItem'] + + +class QueueCondition(Generic[T], TraversalCondition[T]): """ Functional interface representing a condition that determines whether a traversal should queue a next item. `T` The type of items being traversed. """ + def __init__(self, condition): + self.should_queue = condition + def should_queue(self, next_item: T, next_context: StepContext, current_item: T, current_context: StepContext) -> bool: """ Determines whether the [nextItem] should be queued for traversal. @@ -30,22 +39,17 @@ def should_queue(self, next_item: T, next_context: StepContext, current_item: T, `currentContext` The context associated with the [currentItem]. Returns `true` if the [nextItem] should be queued; `false` otherwise. """ - return self._func(next_item, next_context, current_item, current_context) + raise NotImplementedError - def should_queue_start_item(self, item: T) -> bool: + @staticmethod + def should_queue_start_item(item: T) -> bool: """ Determines whether a traversal startItem should be queued when running a [Traversal]. `item` The item to be potentially queued. Returns `true` if the [item] should be queued; `false` otherwise. Defaults to `true`. """ - try: # this is a filthy hack to avoid this being called on a queue condition function that doesnt match this signature - # TODO: this absolute hack of a method to use this as a functional interface needs to go.. - return self._func(item) - except TypeError as e: - if self._func.__code__.co_argcount == 4: - return True - raise e + return True from zepben.evolve.services.network.tracing.traversal.context_value_computer import TypedContextValueComputer @@ -57,4 +61,3 @@ class QueueConditionWithContextValue(QueueCondition[T], TypedContextValueCompute `T` The type of items being traversed. `U` The type of the context value computed and used in the condition. """ - pass diff --git a/src/zepben/evolve/services/network/tracing/traversal/step_action.py b/src/zepben/evolve/services/network/tracing/traversal/step_action.py index 180268082..6de17e3d4 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/step_action.py +++ b/src/zepben/evolve/services/network/tracing/traversal/step_action.py @@ -3,8 +3,7 @@ # 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/. - -from typing import TypeVar, Generic +from typing import TypeVar, Generic, Callable from zepben.evolve.services.network.tracing.traversal.context_value_computer import TypedContextValueComputer from zepben.evolve.services.network.tracing.traversal.step_context import StepContext @@ -12,6 +11,10 @@ T = TypeVar('T') U = TypeVar('U') +__all__ = ['StepAction', 'StepActionWithContextValue', 'StepActionFunc'] + +StepActionFunc = Callable[[T, StepContext], None] + class StepAction(Generic[T]): """ @@ -20,7 +23,7 @@ class StepAction(Generic[T]): `T` The type of items being traversed. """ - def __init__(self, _func): + def __init__(self, _func: StepActionFunc): self._func = _func def apply(self, item: T, context: StepContext): diff --git a/src/zepben/evolve/services/network/tracing/traversal/step_context.py b/src/zepben/evolve/services/network/tracing/traversal/step_context.py index f21259bef..65e72b3d5 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/step_context.py +++ b/src/zepben/evolve/services/network/tracing/traversal/step_context.py @@ -6,6 +6,9 @@ T = TypeVar('T') +__all__ = ['StepContext'] + + class StepContext(Generic[T]): """ Represents the context of a traversal step, holding information about the traversal state and the ability to store arbitrary values with the context. diff --git a/src/zepben/evolve/services/network/tracing/traversal/stop_condition.py b/src/zepben/evolve/services/network/tracing/traversal/stop_condition.py index 5d9fcbf59..f63a0f2fb 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/stop_condition.py +++ b/src/zepben/evolve/services/network/tracing/traversal/stop_condition.py @@ -3,38 +3,45 @@ # 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/. -from typing import TypeVar, Generic +from typing import TypeVar, Generic, Callable from zepben.evolve.services.network.tracing.traversal.context_value_computer import TypedContextValueComputer from zepben.evolve.services.network.tracing.traversal.step_context import StepContext from zepben.evolve.services.network.tracing.traversal.traversal_condition import TraversalCondition - T = TypeVar('T') U = TypeVar('U') +ShouldStop = Callable[[T, StepContext], bool] + +__all__ = ['StopCondition', 'StopConditionWithContextValue', 'ShouldStop'] + -class StopCondition(TraversalCondition[T], Generic[T]): +class StopCondition(Generic[T], TraversalCondition[T]): """ Functional interface representing a condition that determines whether the traversal should stop at a given item. - `T` The type of items being traversed. + T : The type of items being traversed. """ + def __init__(self, stop_function: ShouldStop=None): + if stop_function is not None: + self.should_stop = stop_function + def should_stop(self, item: T, context: StepContext) -> bool: """ Determines whether the traversal should stop at the specified item. - `item` The current item being processed in the traversal. - `context` The context associated with the current traversal step. - Returns `true` if the traversal should stop at this item; `false` otherwise. + :param item: The current item being processed in the traversal. + :param context: The context associated with the current traversal step. + + :return: `True` if the traversal should stop at this item; `False` otherwise. """ - return self._func(item, context) -class StopConditionWithContextValue(StopCondition[T], TypedContextValueComputer[T, U], Generic[T, U]): + +class StopConditionWithContextValue(StopCondition[T], TypedContextValueComputer[T, U]): """ - Interface representing a stop condition that requires a value stored in the [StepContext] to determine if an item should be queued. + Interface representing a stop condition that requires a value stored in the StepContext to determine if an item should be queued. - `T` The type of items being traversed. - `U` The type of the context value computed and used in the condition. + T : The type of items being traversed. + U : The type of the context value computed and used in the condition. """ - pass diff --git a/src/zepben/evolve/services/network/tracing/traversal/traversal.py b/src/zepben/evolve/services/network/tracing/traversal/traversal.py index 32a7a2fb2..f65e8defe 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/traversal.py +++ b/src/zepben/evolve/services/network/tracing/traversal/traversal.py @@ -7,26 +7,28 @@ from abc import abstractmethod from collections import deque -from typing import List, Callable, TypeVar, Generic, Optional, Dict, Union +from collections.abc import Callable +from functools import singledispatchmethod +from typing import List, TypeVar, Generic, Optional, Dict, Union from zepben.evolve import require from zepben.evolve.services.network.tracing.traversal.context_value_computer import ContextValueComputer -from zepben.evolve.services.network.tracing.traversal.queue_condition import QueueCondition, QueueConditionWithContextValue -from zepben.evolve.services.network.tracing.traversal.step_action import StepAction, StepActionWithContextValue +from zepben.evolve.services.network.tracing.traversal.queue_condition import QueueCondition, QueueConditionWithContextValue, ShouldQueue +from zepben.evolve.services.network.tracing.traversal.step_action import StepAction, StepActionWithContextValue, StepActionFunc from zepben.evolve.services.network.tracing.traversal.step_context import StepContext -from zepben.evolve.services.network.tracing.traversal.stop_condition import StopCondition, StopConditionWithContextValue -from zepben.evolve.services.network.tracing.networktrace.conditions.direction_condition import DirectionCondition -from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep +from zepben.evolve.services.network.tracing.traversal.stop_condition import StopCondition, StopConditionWithContextValue, ShouldStop __all__ = ["Traversal"] -from zepben.evolve.services.network.tracing.traversal.traversal_condition import TraversalCondition from zepben.evolve.services.network.tracing.traversal.queue import TraversalQueue T = TypeVar('T') U = TypeVar('U') D = TypeVar('D', bound='Traversal') +QueueConditionTypes = Union[ShouldQueue, QueueCondition[T]] +StopConditionTypes = Union[ShouldStop, StopCondition[T]] +ConditionTypes = Union[QueueConditionTypes, StopConditionTypes] class Traversal(Generic[T, D]): @@ -51,7 +53,7 @@ class QueueType(Generic[T, D]): """ Defines the types of queues used in the traversal. """ - queue_next:Traversal.QueueNext[T] + queue_next: Traversal.QueueNext[T] @property def queue(self) -> TraversalQueue[T]: @@ -107,23 +109,23 @@ def queue(self) -> TraversalQueue[T]: def branch_queue(self) -> Optional[TraversalQueue[D]]: return self.branch_queue_factory() - - _queue_type: Union[BasicQueueType, BranchingQueueType] = None + _queue_type: Union[BasicQueueType, BranchingQueueType] def __init__(self, queue_type, parent: Optional[D] = None): - if self._queue_type is None: - self._queue_type = queue_type + self._queue_type = queue_type self._parent: D = parent - self._queue_next = { - Traversal.BasicQueueType: lambda current, context: self._queue_next_non_branching(current, context, self._queue_type.queue_next), - Traversal.BranchingQueueType: lambda current, context: self._queue_next_branching(current, context, self._queue_type.queue_next), - } + if type(self._queue_type) == Traversal.BasicQueueType: + self.queue_next = lambda current, context: self._queue_next_non_branching(current, context, self._queue_type.queue_next) + elif type(self._queue_type) == Traversal.BranchingQueueType: + self.queue_next = lambda current, context: self._queue_next_branching(current, context, self._queue_type.queue_next) - self.queue: TraversalQueue[T] = queue_type.queue - self.branch_queue: Optional[TraversalQueue[D]] = queue_type.branch_queue + self.queue: TraversalQueue[T] = self._queue_type.queue + self.branch_queue: Optional[TraversalQueue[D]] = self._queue_type.branch_queue self.start_items: deque[T] = deque() + self.running: bool = False + self.has_run: bool = False self.stop_conditions: List[StopCondition[T]] = [] self.queue_conditions: List[QueueCondition[T]] = [] @@ -131,9 +133,8 @@ def __init__(self, queue_type, parent: Optional[D] = None): self.compute_next_context_funs: Dict[str, ContextValueComputer[T]] = {} self.contexts: Dict[T, StepContext] = {} - @property - def queue_next(self): - return self._queue_next[self._queue_type.__class__] + def queue_next(self, current_item: T, context: StepContext): + raise NotImplementedError @property def parent(self): @@ -145,7 +146,7 @@ def parent(self, value): self._parent = value raise Exception - def can_action_item(self, item: T, context: 'StepContext') -> bool: + def can_action_item(self, item: T, context: StepContext) -> bool: """ Determines if the traversal can apply step actions and stop conditions on the specified item. @@ -155,7 +156,7 @@ def can_action_item(self, item: T, context: 'StepContext') -> bool: """ return True - def can_visit_item(self, item: T, context: 'StepContext') -> bool: + def can_visit_item(self, item: T, context: StepContext) -> bool: raise NotImplementedError def create_new_this(self) -> D: @@ -166,30 +167,30 @@ def create_new_this(self) -> D: """ raise NotImplementedError - def add_condition(self, condition: Union[QueueCondition, Callable[[NetworkTraceStep[T], StepContext], None]]) -> D: + @singledispatchmethod + def add_condition(self, condition: ConditionTypes) -> D: """ Adds a traversal condition to the traversal. - `condition` The condition to add. - Returns this traversal instance. + :param condition: The condition to add. + + :return: this traversal instance. """ - if callable(condition): + if callable(condition): # Callable[[NetworkTraceStep[T], StepContext], None] if condition.__code__.co_argcount == 2: return self.add_stop_condition(condition) elif condition.__code__.co_argcount == 4: return self.add_queue_condition(condition) - raise RuntimeError(f'Condition does not match expected: Number of args is not 2(Stop Condition) or 4(QueueCondition)') - - assert issubclass(condition.__class__, (QueueCondition, StopCondition, DirectionCondition)) - if isinstance(condition, (QueueCondition, DirectionCondition)): - return self.add_queue_condition(condition) - elif isinstance(condition, StopCondition): - return self.add_stop_condition(condition) + else: + raise RuntimeError(f'Condition does not match expected: Number of args is not 2(Stop Condition) or 4(QueueCondition)') else: - raise RuntimeError(f'Condition does not match expected: {condition.__class__.__name__}') + raise RuntimeError(f'Condition [{condition.__class__.__name__}] does not match expected: ' + + "[QueueCondition | DirectionCondition | StopCondition | Callable[_,_] | Callable[_,_,_,_]]") - def add_stop_condition(self, condition: Union[Callable, StopCondition[T], StopConditionWithContextValue[T, U]]) -> D: + @singledispatchmethod + @add_condition.register(StopCondition) + def add_stop_condition(self, condition: StopConditionTypes) -> D: """ Adds a stop condition to the traversal. If any stop condition returns `true`, the traversal will not call the callback to queue more items from the current item. @@ -197,15 +198,18 @@ def add_stop_condition(self, condition: Union[Callable, StopCondition[T], StopCo `condition` The stop condition to add. Returns this traversal instance. """ - if callable(condition): - return self.add_stop_condition(StopCondition(condition)) + raise RuntimeError(f'Condition [{condition.__class__.__name__}] does not match expected: [StopCondition | StopConditionWithContextValue | Callable]') - elif isinstance(condition, StopCondition): - self.stop_conditions.append(condition) - if issubclass(condition.__class__, StopConditionWithContextValue): - self.compute_next_context_funs[condition.key] = condition - return self - raise RuntimeError(f'Condition does not match expected: {condition.__class__.__name__}') + @add_stop_condition.register(Callable) + def _(self, condition: ShouldStop): + return self.add_stop_condition(StopCondition(condition)) + + @add_stop_condition.register + def _(self, condition: StopCondition): + self.stop_conditions.append(condition) + if isinstance(condition, StopConditionWithContextValue): + self.compute_next_context_funs[condition.key] = condition + return self def copy_stop_conditions(self, other: Traversal[T, D]) -> D: """ @@ -224,7 +228,9 @@ def matches_any_stop_condition(self, item: T, context: StepContext) -> bool: return True return False - def add_queue_condition(self, condition: Union[Callable, QueueCondition[T]]) -> D: + @add_condition.register(QueueCondition) + @singledispatchmethod + def add_queue_condition(self, condition: QueueConditionTypes) -> D: """ Adds a queue condition to the traversal. Queue conditions determine whether an item should be queued for traversal. All registered queue conditions must return true for an item to be queued. @@ -232,17 +238,18 @@ def add_queue_condition(self, condition: Union[Callable, QueueCondition[T]]) -> :param condition: The queue condition to add. :returns: The current traversal instance. """ - if callable(condition): - return self.add_queue_condition(QueueCondition(condition)) + raise RuntimeError(f'Condition [{condition.__class__.__name__}] does not match expected: [QueueCondition | QueueConditionWithContextValue | Callable]') - elif isinstance(condition, QueueCondition): - assert issubclass(condition.__class__, QueueCondition) - self.queue_conditions.append(condition) - if isinstance(condition, QueueConditionWithContextValue): - self.compute_next_context_funs[condition.key] = condition - return self - raise RuntimeError(f'Condition does not match expected: {condition.__class__.__name__}') + @add_queue_condition.register(Callable) + def _(self, condition: ShouldQueue): + return self.add_queue_condition(QueueCondition(condition)) + @add_queue_condition.register + def _(self, condition: QueueCondition): + self.queue_conditions.append(condition) + if isinstance(condition, QueueConditionWithContextValue): + self.compute_next_context_funs[condition.key] = condition + return self def copy_queue_conditions(self, other: Traversal[T, D]) -> D: """ @@ -255,25 +262,25 @@ def copy_queue_conditions(self, other: Traversal[T, D]) -> D: self.add_queue_condition(it) return self - def add_step_action(self, action: Union[Callable, StepAction[T]]) -> D: + def add_step_action(self, action: Union[StepActionFunc, StepAction[T]]) -> D: """ Adds an action to be performed on each item in the traversal, including the starting items. `action` The action to perform on each item. Returns The current traversal instance. """ - if callable(action): - return self.add_step_action(StepAction(action)) - - elif isinstance(action, StepAction): - assert issubclass(action.__class__, StepAction) or isinstance(action, StepAction) + if isinstance(action, StepAction): self.step_actions.append(action) if isinstance(action, StepActionWithContextValue): self.compute_next_context_funs[action.key] = action return self - raise RuntimeError(f'Condition does not match expected: {action.__class__.__name__}') - def if_not_stopping(self, action: Callable) -> D: + elif callable(action): + return self.add_step_action(StepAction(action)) + + raise RuntimeError(f'Condition [{action.__class__.__name__}] does not match expected: [StepAction | StepActionWithContextValue | Callable]') + + def if_not_stopping(self, action: Callable[[T, StepContext], None]) -> D: """ Adds an action to be performed on each item that does not match any stop condition. @@ -284,7 +291,7 @@ def if_not_stopping(self, action: Callable) -> D: return self - def if_stopping(self, action: Callable) -> D: + def if_stopping(self, action: Callable[[T, StepContext], None]) -> D: """ Adds an action to be performed on each item that matches a stop condition. @@ -320,7 +327,7 @@ def add_context_value_computer(self, computer: ContextValueComputer[T]) -> D: `computer` The context value computer to add. Returns The current traversal instance. """ - require(not isinstance(computer, TraversalCondition), lambda: "`computer` must not be a TraversalCondition. Use `addCondition` to add conditions that also compute context values") + #require(not issubclass(computer.__class__, TraversalCondition), lambda: "`computer` must not be a TraversalCondition. Use `addCondition` to add conditions that also compute context values") self.compute_next_context_funs[computer.key] = computer return self @@ -370,7 +377,7 @@ async def run(self, start_item: T=None, can_stop_on_start_item: bool=True) -> D: `canStopOnStartItem` Indicates if the traversal should check stop conditions on the starting item. Returns The current traversal instance. """ - if start_item: + if start_item is not None: self.start_items.append(start_item) require(not self.running, lambda: "Traversal is already running") @@ -383,10 +390,13 @@ async def run(self, start_item: T=None, can_stop_on_start_item: bool=True) -> D: if self._parent is None and isinstance(self._queue_type, Traversal.BranchingQueueType) and len(self.start_items) > 1: self._branch_start_items() + # Because we don't traverse anything at the top level parent, we need to pass can_stop_at_start item + # to the child branch only in this case because they are actually start items. + await self._traverse_branches(can_stop_on_start_item) else: await self._traverse(can_stop_on_start_item) - - await self._traverse_branches(can_stop_on_start_item) + # Child branches should never stop at start items because a branch start item is not a whole trace start item. + await self._traverse_branches(True) self.running = False return self @@ -422,34 +432,38 @@ def _branch_start_items(self): if self.branch_queue is None: raise Exception("INTERNAL ERROR: self.branch_queue should never be null here") - self.branch_queue.put(branch) + self.branch_queue.append(branch) async def _traverse(self, can_stop_on_start_item: bool): while len(self.start_items) > 0: start_item = self.start_items.popleft() + # If the traversal is not a branch we need to compute an initial context and check if it + # should even be queued to trace. If the traversal is a branch, the branch creators should + # have only created the branch if the item was eligible to be queued and added the item + # context as part of the branch creation. if self._parent is None: if self._can_queue_start_item(start_item): self.contexts[start_item] = self._compute_intial_context(start_item) - self.queue.put(start_item) + self.queue.append(start_item) else: - self.queue.put(start_item) + self.queue.append(start_item) - can_stop = can_stop_on_start_item - while len(self.queue) > 0: - current = self.queue.pop() - context = self._get_step_context(current) - if self.can_visit_item(current, context): - context.is_actionable_item = self.can_action_item(current, context) + while self.queue.has_next(): + current = self.queue.pop() + context = self._get_step_context(current) + can_stop = can_stop_on_start_item or (not context.is_start_item) + if self.can_visit_item(current, context): + context.is_stopping = can_stop and self.matches_any_stop_condition(current, context) - if context.is_actionable_item: - context.is_stopping = can_stop and self.matches_any_stop_condition(current, context) - await self.apply_step_actions(current, context) + context.is_actionable_item = self.can_action_item(current, context) - if not context.is_stopping: - self.queue_next(current, context) + if context.is_actionable_item: + await self.apply_step_actions(current, context) + + if not context.is_stopping: + self.queue_next(current, context) - can_stop = True def _get_step_context(self, item: T) -> StepContext: try: @@ -459,23 +473,26 @@ def _get_step_context(self, item: T) -> StepContext: raise KeyError("INTERNAL ERROR: Traversal item should always have a context.") def _create_new_branch(self, start_item: T, context: StepContext) -> D: - it = self.create_new_this() - it.copy_queue_conditions(self) - it.copy_step_actions(self) - it.copy_stop_conditions(self) - it.copy_context_value_computer(self) + it = ( + self.create_new_this() + .copy_queue_conditions(self) + .copy_step_actions(self) + .copy_stop_conditions(self) + .copy_context_value_computer(self) + ) it.contexts[start_item] = context - it.add_start_item(start_item) + Traversal.add_start_item(it, start_item) return it def _item_queuer(self, current_item: T, current_context) -> Callable[[T], bool]: def inner(next_item: T) -> bool: next_context = self._compute_next_context(current_item, current_context, next_item, is_branch_start=False) - if self._can_queue_item(next_item, next_context, current_item, current_context) and self.queue.put(next_item): + if self._can_queue_item(next_item, next_context, current_item, current_context) and self.queue.append(next_item): self.contexts[next_item] = next_context return True - return False + else: + return False return inner @@ -487,9 +504,11 @@ def queue_branch(next_item: T): next_context = self._compute_next_context(current, current_context, next_item, is_branch_start=True) if self._can_queue_item(next_item, next_context, current, current_context): branch = self._create_new_branch(next_item, next_context) - self.branch_queue.put(branch) + self.branch_queue.append(branch) return True - return False + else: + return False + return queue_next.accept(current, current_context, self._item_queuer(current, current_context), queue_branch) async def _traverse_branches(self, can_stop_on_start_item: bool): diff --git a/src/zepben/evolve/services/network/tracing/traversal/traversal_condition.py b/src/zepben/evolve/services/network/tracing/traversal/traversal_condition.py index 063683d23..164690202 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/traversal_condition.py +++ b/src/zepben/evolve/services/network/tracing/traversal/traversal_condition.py @@ -3,11 +3,19 @@ # 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/. -from abc import ABC -from typing import TypeVar, Generic +from typing import TypeVar, Protocol, runtime_checkable T = TypeVar('T') -class TraversalCondition(ABC, Generic[T]): - def __init__(self, _func): - self._func = _func \ No newline at end of file +__all__ = ['TraversalCondition'] + +@runtime_checkable +class TraversalCondition(Protocol[T]): + """ + Protocol, representing a condition used in a traversal. + Implementations of this interface can influence the traversal process by determining + things such as the ability to queue items,stop at specific items, or apply other + conditional logic during traversal + + T : The type of items being processed + """ diff --git a/src/zepben/evolve/services/network/tracing/traversal/weighted_priority_queue.py b/src/zepben/evolve/services/network/tracing/traversal/weighted_priority_queue.py index 2f255f434..814708ffb 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/weighted_priority_queue.py +++ b/src/zepben/evolve/services/network/tracing/traversal/weighted_priority_queue.py @@ -3,14 +3,17 @@ # 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/. from collections import defaultdict -from typing import TypeVar, Callable, Iterable, Any +from typing import TypeVar, Callable, Iterable +from zepben.evolve.services.network.tracing.traversal.traversal import Traversal from zepben.evolve.services.network.tracing.traversal.queue import TraversalQueue - T = TypeVar('T') U = TypeVar('U') +__all__ = ['WeightedPriorityQueue'] + + class SortedDefaultDict(defaultdict): def keys(self): return sorted(super().keys()) @@ -26,48 +29,56 @@ class WeightedPriorityQueue(TraversalQueue[T]): :param queue_provider: A queue provider. This allows you to customise the priority of items with the same weight. :param get_weight: A method to extract the weight of an item being added to the queue. """ - def __init__(self, queue_provider: Callable[[], TraversalQueue[T]], get_weight: Callable[[Any], int]): + + def __init__(self, queue_provider: Callable[[], TraversalQueue[T]], get_weight: Callable[[T], int]): self._queue_provider = queue_provider self._get_weight = get_weight - super().__init__(queue=SortedDefaultDict(self._queue_provider)) + + self.queue: SortedDefaultDict[int, TraversalQueue[T]] = SortedDefaultDict(self._queue_provider) def __len__(self) -> int: """need to aggregate the lengths of all queues""" return sum(len(v) for v in self.queue.values()) - def __iter__(self): - return self - - def __next__(self): - yield self.pop() - def pop(self): - for weight in self.queue.keys(): + for weight in reversed(self.queue.keys()): if self.queue[weight].has_next(): return self.queue[weight].pop() - def put(self, item: T) -> bool: + def append(self, item: T) -> bool: weight = self._get_weight(item) - if weight < 0: - raise Exception - self.queue[weight].put(item) + self.queue[weight].append(item) return True def extend(self, items: Iterable[T]) -> bool: raise NotImplementedError() @classmethod - def process_queue(cls, get_weight: Callable[[T], int]) -> TraversalQueue: + def process_queue(cls, get_weight: Callable[[T], int]) -> TraversalQueue[T]: """Special priority queue that queues items with the largest weight as the highest priority.""" return cls(TraversalQueue.depth_first, get_weight) @classmethod - def branch_queue(cls, get_weight: Callable[[T], int]) -> TraversalQueue: + def branch_queue(cls, get_weight: Callable[[T], int]) -> TraversalQueue[T]: """Special priority queue that queues branch items with the largest weight on the starting item as the highest priority""" - def condition(traversal): + def condition(traversal: Traversal): items = traversal.start_items if len(items) == 0: - return None - return get_weight(items) or -1 + return -1 + return get_weight(items[0]) or -1 return cls(TraversalQueue.breadth_first, condition) + + def has_next(self) -> bool: + for weight in self.queue.keys(): + _next = self.queue.get(weight) + if _next: + return True + return False + + + def peek(self) -> T: + raise Exception + + def clear(self): + self.queue.clear() diff --git a/src/zepben/evolve/services/network/tracing/util.py b/src/zepben/evolve/services/network/tracing/util.py index d18fd7dae..6817a923f 100644 --- a/src/zepben/evolve/services/network/tracing/util.py +++ b/src/zepben/evolve/services/network/tracing/util.py @@ -8,7 +8,11 @@ import logging from typing import Optional -from zepben.evolve import Switch, ConductingEquipment, SinglePhaseKind, Traversal + +from zepben.evolve.model.cim.iec61970.base.wires.switch import Switch +from zepben.evolve.model.cim.iec61970.base.core.conducting_equipment import ConductingEquipment +from zepben.evolve.model.cim.iec61970.base.wires.single_phase_kind import SinglePhaseKind +from zepben.evolve.services.network.tracing.traversal.traversal import Traversal __all__ = ["normally_open", "currently_open", "ignore_open", "phase_log"] phase_logger = logging.getLogger("phase_logger") diff --git a/src/zepben/evolve/testing/test_network_builder.py b/src/zepben/evolve/testing/test_network_builder.py index 1ef758be3..7c327c373 100644 --- a/src/zepben/evolve/testing/test_network_builder.py +++ b/src/zepben/evolve/testing/test_network_builder.py @@ -2,18 +2,16 @@ # 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/. + from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing -try: - from typing import Protocol, Any -except ImportError: - Protocol = object -from typing import Optional, Callable, List, Union, Type +from typing import Optional, Callable, List, Union, Type, TypeVar, Protocol from zepben.evolve import ConductingEquipment, NetworkService, PhaseCode, EnergySource, AcLineSegment, Breaker, Junction, Terminal, Feeder, LvFeeder, \ - PowerTransformerEnd, PowerTransformer, EnergyConsumer, \ - PowerElectronicsConnection + PowerTransformerEnd, PowerTransformer, EnergyConsumer, PowerElectronicsConnection, BusbarSection, Clamp, Cut, Site + +SubclassesConductingEquipment = TypeVar('SubclassesConductingEquipment', bound=ConductingEquipment) def null_action(_): @@ -27,7 +25,7 @@ def null_action(_): class OtherCreator(Protocol): """Type hint class""" - def __call__(self, mrid: str, *args, **kwargs) -> ConductingEquipment: Any + def __call__(self, mrid: str, *args, **kwargs) -> ConductingEquipment: ... class TestNetworkBuilder: @@ -343,13 +341,59 @@ def to_energy_consumer( self._current = it return self + def from_busbar_section( + self, + nominal_phases: PhaseCode = PhaseCode.ABC, + mrid: str = None, + action: Callable[[BusbarSection], None] = null_action + ) -> 'TestNetworkBuilder': + """ + Start a new network island from a `BusbarSection`, updating the network pointer to the new `BusbarSection`. + + :param nominal_phases: The nominal phases for the new `BusbarSection`. + :param mrid: Optional mRID for the new `BusbarSection`. + :param action: An action that accepts the new `BusbarSection` to allow for additional initialisation. + + :return: This `TestNetworkBuilder` to allow for fluent use. + """ + it = self._create_busbar_section(mrid, nominal_phases) + action(it) + self._current = it + return self + + def to_busbar_section( + self, + nominal_phases: PhaseCode = PhaseCode.ABC, + mrid: str = None, + connectivity_node_mrid: Optional[str] = None, + action: Callable[[BusbarSection], None] = null_action + ) -> 'TestNetworkBuilder': + """ + + Add a new `BusbarSection` to the network and connect it to the current network pointer, updating the network pointer to the new `BusbarSection`. + + :param nominal_phases: The nominal phases for the new `BusbarSection`. + :param mrid: Optional mRID for the new `BusbarSection`. + :param connectivity_node_mrid: Optional id of the connectivity node used to connect this `BusbarSection` to the previous item. Will only be used + if the previous item is not already connected. + :param action: An action that accepts the new `BusbarSection` to allow for additional initialisation. + + :return: This `TestNetworkBuilder` to allow for fluent use. + """ + it = self._create_busbar_section(mrid, nominal_phases) + self._connect(self._current, it, connectivity_node_mrid) + action(it) + self._current = it + return self + def from_other( self, - creator: Union[OtherCreator, Type[ConductingEquipment]], + creator: Union[OtherCreator, Type[SubclassesConductingEquipment]], nominal_phases: PhaseCode = PhaseCode.ABC, num_terminals: Optional[int] = None, mrid: Optional[str] = None, - action: Callable[[ConductingEquipment], None] = null_action + action: Callable[[SubclassesConductingEquipment], None] = null_action, + default_mrid_prefix: Optional[str] = None ) -> 'TestNetworkBuilder': """ Start a new network island from a `ConductingEquipment` created by `creator`, updating the network pointer to the new `ConductingEquipment`. @@ -360,22 +404,26 @@ def from_other( :param num_terminals: The number of terminals to create on the new `ConductingEquipment`. Defaults to 2. :param mrid: Optional mRID for the new `ConductingEquipment`. :param action: An action that accepts the new `ConductingEquipment` to allow for additional initialisation. + :param default_mrid_prefix: mRID prefix to use for the new `ConductingEquipment` :return: This `TestNetworkBuilder` to allow for fluent use. """ - it = self._create_other(mrid, creator, nominal_phases, num_terminals) + if mrid and default_mrid_prefix: + raise ValueError('cant specify both mrid and default_mrid_prefix as your intention is unclear') + it = self._create_other(mrid, creator, nominal_phases, num_terminals, default_mrid_prefix=default_mrid_prefix) action(it) self._current = it return self def to_other( self, - creator: Union[OtherCreator, Type[ConductingEquipment]], + creator: Union[OtherCreator, Type[SubclassesConductingEquipment]], nominal_phases: PhaseCode = PhaseCode.ABC, num_terminals: Optional[int] = None, mrid: Optional[str] = None, connectivity_node_mrid: Optional[str] = None, - action: Callable[[ConductingEquipment], None] = null_action + action: Callable[[SubclassesConductingEquipment], None] = null_action, + default_mrid_prefix: Optional[str] = None ) -> 'TestNetworkBuilder': """ Add a new `ConductingEquipment` to the network and connect it to the current network pointer, updating the network pointer to the new @@ -389,15 +437,83 @@ def to_other( :param connectivity_node_mrid: Optional id of the connectivity node used to connect this `ConductingEquipment` to the previous item. Will only be used if the previous item is not already connected. :param action: An action that accepts the new `ConductingEquipment` to allow for additional initialisation. + :param default_mrid_prefix: mRID prefix to use for the new `ConductingEquipment` :return: This `TestNetworkBuilder` to allow for fluent use. """ - it = self._create_other(mrid, creator, nominal_phases, num_terminals) + if mrid and default_mrid_prefix: + raise ValueError('cant specify both mrid and default_mrid_prefix as your intention is unclear') + it = self._create_other(mrid, creator, nominal_phases, num_terminals, default_mrid_prefix=default_mrid_prefix) self._connect(self._current, it, connectivity_node_mrid) action(it) self._current = it return self + def with_clamp( + self, + mrid: Optional[str] = None, + length_from_terminal_1: float = None, + action: Callable[[Clamp], None] = null_action + ) -> 'TestNetworkBuilder': + """ + Create a clamp on the current network pointer (must be an `AcLineSegment`) without moving the current network pointer. + + :param mrid: Optional mRID for the new `Clamp` + :param length_from_terminal_1: The length from terminal 1 of the `AcLineSegment` being clamped + :param action: An action that accepts the new `Clamp` to allow for additional initialisation. + + :return: This `TestNetworkBuilder` to allow for fluent use + """ + acls = self._current + if not isinstance(acls, AcLineSegment): + raise ValueError("`with_clamp` can only be called when the last added item was an AcLineSegment") + + clamp = Clamp(mrid=mrid or f'{acls.mrid}-clamp{acls.num_clamps() + 1}', length_from_terminal_1=length_from_terminal_1) + clamp.add_terminal(Terminal(mrid=f'{clamp.mrid}-t1')) + + acls.add_clamp(clamp) + action(clamp) + self.network.add(clamp) + return self + + def with_cut( + self, + mrid: Optional[str] = None, + length_from_terminal_1: Optional[float] = None, + is_normally_open: bool = True, + is_open: bool = None, + action: Callable[[Cut], None] = null_action + ) -> 'TestNetworkBuilder': + """ + Create a cut on the current network pointer (must be an `AcLineSegment`) without moving the current network pointer. + + :param mrid: Optional mRID for the new `Cut` + :param length_from_terminal_1: The length from terminal 1 of the `AcLineSegment` being cut + :param is_normally_open: The normal state of the cut, defaults to True + :param is_open: The current state of the cut. Defaults to `is_normally_open` + :param action: An action that accepts the new `Cut` to allow for additional initialisation. + + :return: This `TestNetworkBuilder` to allow for fluent use + """ + acls = self._current + if not isinstance(acls, AcLineSegment): + raise ValueError("`with_cut` can only be called when the last added item was an AcLineSegment") + + cut = Cut(mrid=mrid or f'{acls.mrid}-cut{acls.num_cuts() + 1}', length_from_terminal_1=length_from_terminal_1) + cut.add_terminal(Terminal(mrid=f'{cut.mrid}-t1')) + cut.add_terminal(Terminal(mrid=f'{cut.mrid}-t2')) + + cut.set_normally_open(is_normally_open) + if is_open is None: + cut.set_open(is_normally_open) + else: + cut.set_open(is_open) + + acls.add_cut(cut) + action(cut) + self.network.add(cut) + return self + def branch_from(self, from_: str, terminal: Optional[int] = None) -> 'TestNetworkBuilder': """ Move the current network pointer to the specified `from` allowing branching of the network. This has the effect of changing the current network pointer. @@ -411,6 +527,33 @@ def branch_from(self, from_: str, terminal: Optional[int] = None) -> 'TestNetwor self._current_terminal = terminal return self + def connect_to( + self, + to: str, + to_terminal: int = None, + from_terminal: int = None, + connectivity_node_mrid: Optional[str] = None + ) -> 'TestNetworkBuilder': + """ + Connect to current network pointer to the specified `to` without moving the current network pointer. + + :param to: The mRID of the second `ConductingEquipment` to be connected. + :param to_terminal: The sequence number or terminal on `to` which will be connected. + :param from_terminal: Optional sequence number of the terminal on current network pointer which will be connected. + :param connectivity_node_mrid: Optional id of the connectivity node used to connect the terminals. Will only be used if both terminals are not already + connected. + :return: This `TestNetworkBuilder` to allow for fluent use. + """ + + self._connect( + self._current, + self.network.get(to, ConductingEquipment), + connectivity_node_mrid, + from_terminal, + to_terminal + ) + return self + def connect( self, from_: str, @@ -466,6 +609,25 @@ def add_lv_feeder(self, head_mrid: str, sequence_number: Optional[int] = None, m self._create_lv_feeder(mrid, self.network.get(head_mrid, ConductingEquipment), sequence_number) return self + def add_site(self, equipment_mrids: List[str], mrid: Optional[str] = None) -> 'TestNetworkBuilder': + """ + Create a new Site containing the specified equipment. + + :param equipment_mrids: The mRID's of the equipment to add to the site + :param mrid: Optional mRID for the new `Site`. + :return: This [TestNetworkBuilder] to allow for fluent use. + """ + + site = Site(mrid=self._next_id(mrid, 'site')) + + for _id in equipment_mrids: + ce = self.network[_id] + site.add_equipment(ce) + ce.add_container(site) + self.network.add(site) + + return self + async def build(self, apply_directions_from_sources: bool = True, assign_feeders: bool = True) -> NetworkService: """ Get the `NetworkService` after apply traced phasing and feeder directions. @@ -555,6 +717,13 @@ def _create_junction(self, mrid: Optional[str], nominal_phases: PhaseCode, num_t self.network.add(j) return j + def _create_busbar_section(self, mrid: Optional[str], nominal_phases: PhaseCode) -> BusbarSection: + b = BusbarSection(mrid=self._next_id(mrid, 'bbs')) + self._add_terminal(b, 1, nominal_phases) + + self.network.add(b) + return b + def _create_power_electronics_connection(self, mrid: Optional[str], nominal_phases: PhaseCode, num_terminals: Optional[int]) -> PowerElectronicsConnection: pec = PowerElectronicsConnection(mrid=self._next_id(mrid, "pec")) for i in range(1, (num_terminals if num_terminals is not None else 2) + 1): @@ -594,11 +763,12 @@ def _create_energy_consumer(self, mrid: Optional[str], nominal_phases: PhaseCode def _create_other( self, mrid: Optional[str], - creator: Union[OtherCreator, Type[ConductingEquipment]], + creator: Union[OtherCreator, Type[SubclassesConductingEquipment]], nominal_phases: PhaseCode, - num_terminals: Optional[int] - ) -> ConductingEquipment: - o = creator(mrid=self._next_id(mrid, "o")) + num_terminals: Optional[int], + default_mrid_prefix: Optional[str] = None + ) -> SubclassesConductingEquipment: + o = creator(mrid=self._next_id(mrid, default_mrid_prefix or "o")) for i in range(1, (num_terminals if num_terminals is not None else 2) + 1): self._add_terminal(o, i, nominal_phases) diff --git a/src/zepben/evolve/testing/test_traversal.py b/src/zepben/evolve/testing/test_traversal.py deleted file mode 100644 index 39ed56aa6..000000000 --- a/src/zepben/evolve/testing/test_traversal.py +++ /dev/null @@ -1,66 +0,0 @@ -# Copyright 2024 Zeppelin Bend Pty Ltd -# 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/. - -from typing import Callable, TypeVar, Tuple, Awaitable -from unittest.mock import Mock - -T = TypeVar('T') - -__all__ = ["verify_stop_conditions", "step_on_when_run", "step_on_when_run_with_is_stopping"] - - -async def verify_stop_conditions(traversal: Mock, *stop_condition_validation: Callable[[Callable[[T], Awaitable[None]]], Awaitable[None]]): - """ - Verify that stop conditions are registered, and they behave correctly. - - :param traversal: The mocked `BasicTraversal` to verify. - :param stop_condition_validation: A collection of verification blocks that are executed on each stop condition. The number of entries in this collection - must match the number of expected stop conditions, and match the registration order of the stop conditions. - """ - # To get access to private stop conditions (and to check they are actually registered) we need to capture the conditions that are registered. - assert traversal.add_stop_condition.call_count == len(stop_condition_validation) - - for index, stop_condition_call in enumerate(traversal.add_stop_condition.call_args_list): - await stop_condition_validation[index](stop_condition_call.args[0]) - - -def step_on_when_run(traversal: Mock, *step_on: T): - """ - Call the step action with the specified arguments when the trace is run. Only supports single step actions. - - :param traversal: The mocked `BasicTraversal` that will be run. - :param step_on: A collection of items to step on when `traversal` is run. - """ - - # The step actions must be called while the trace is running, so this needs to be done in the mock of the run command. - async def mock_run(_): - traversal.add_step_action.assert_called_once() - - # To get access to private step actions (and to check they are actually registered) we need to capture the actions that are registered. - step_action = traversal.add_step_action.call_args.args[0] - for item in step_on: - await step_action(item, False) - - traversal.run.side_effect = mock_run - - -def step_on_when_run_with_is_stopping(traversal: Mock, *step_on: Tuple[T, bool]): - """ - Call the step action with the specified arguments when the trace is run. Only supports single step actions. - - :param traversal: The mocked `BasicTraversal` that will be run. - :param step_on: A collection of items and is_stopping flags to step on when `traversal` is run. - """ - - # The step actions must be called while the trace is running, so this needs to be done in the mock of the run command. - async def mock_run(_): - traversal.add_step_action.assert_called_once() - - # To get access to private step actions (and to check they are actually registered) we need to capture the actions that are registered. - step_action = traversal.add_step_action.call_args.args[0] - for (item, is_stopping) in step_on: - await step_action(item, is_stopping) - - traversal.run.side_effect = mock_run diff --git a/src/zepben/evolve/types.py b/src/zepben/evolve/types.py index e62d59ab7..bf5ccc9c6 100644 --- a/src/zepben/evolve/types.py +++ b/src/zepben/evolve/types.py @@ -14,8 +14,7 @@ T = TypeVar("T") -__all__ = ["OpenTest", "PhaseSelector", "DirectionSelector"] +__all__ = ["OpenTest"] OpenTest = Callable[[ConductingEquipment, Optional[SinglePhaseKind]], bool] -PhaseSelector = Callable[[Terminal], PhaseStatus] -DirectionSelector = Callable[[Terminal], DirectionStatus] + diff --git a/src/zepben/evolve/util.py b/src/zepben/evolve/util.py index a42f3398b..0d958d62a 100644 --- a/src/zepben/evolve/util.py +++ b/src/zepben/evolve/util.py @@ -1,4 +1,4 @@ -# Copyright 2024 Zeppelin Bend Pty Ltd +# Copyright 2025 Zeppelin Bend Pty Ltd # 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/. @@ -6,7 +6,7 @@ from __future__ import annotations __all__ = ["get_by_mrid", "contains_mrid", "safe_remove", "safe_remove_by_id", "nlen", "ngen", "is_none_or_empty", "require", "pb_or_none", "CopyableUUID", - "datetime_to_timestamp", "none"] + "datetime_to_timestamp", "none", "classproperty"] import os import re @@ -167,6 +167,11 @@ def copy(): return str(UUID(bytes=os.urandom(16), version=4)) +class classproperty(property): + def __get__(self, cls, owner: T) -> T: + return classmethod(self.fget).__get__(None, owner)() + + def datetime_to_timestamp(date_time: datetime) -> PBTimestamp: timestamp = PBTimestamp() timestamp.FromDatetime(date_time) diff --git a/test/busbranch/data/negligible_impedance_equipment_basic_network.py b/test/busbranch/data/negligible_impedance_equipment_basic_network.py index a1810c6de..27e8d7d1c 100644 --- a/test/busbranch/data/negligible_impedance_equipment_basic_network.py +++ b/test/busbranch/data/negligible_impedance_equipment_basic_network.py @@ -33,7 +33,7 @@ def negligible_impedance_equipment_basic_network(nie_constructor) -> NetworkServ # NegligibleImpedanceEquipment1 nie1 = nie_constructor("nie1") network.add(nie1) - nie1_ts = create_terminals(network, nie1, 1) + nie1_ts = create_terminals(network, nie1, 2) network.connect_terminals(a0_t, nie1_ts[0]) @@ -42,7 +42,7 @@ def negligible_impedance_equipment_basic_network(nie_constructor) -> NetworkServ network.add(a1) a1_ts = create_terminals(network, a1, 2) - network.connect_terminals(nie1_ts[0], a1_ts[0]) + network.connect_terminals(nie1_ts[1], a1_ts[0]) # AcLineSegment2 a2 = AcLineSegment(mrid="a2", length=2.0, per_length_impedance=plsi) diff --git a/test/busbranch/test_bus_branch.py b/test/busbranch/test_bus_branch.py index f5a1a31fa..08b69c4e3 100644 --- a/test/busbranch/test_bus_branch.py +++ b/test/busbranch/test_bus_branch.py @@ -239,9 +239,10 @@ def has_neg_imp(ce) -> bool: await _validate_term_grouping(has_neg_imp, nb_network, "a6_a7", set(), set(), {get_term(a6, 2), *a7.terminals}) -@pytest.mark.skip() # FIXME: @pytest.mark.asyncio -@given(nie_constructor=sampled_from([Junction, Disconnector, BusbarSection])) +@given(nie_constructor=sampled_from([Junction, Disconnector])) # BusbarSection used to be included in this test, but having 1 terminal breaks the logic + # and it got really messy making it uniform, suggest reworking this test using +# # TestNetworkBuilder, and some less verbose logic. async def test_group_negligible_impedance_terminals_groups_negligible_impedance_equipment(nie_constructor): nb_network = negligible_impedance_equipment_basic_network(lambda mrid: nie_constructor(mrid=mrid)) diff --git a/test/cim/cim_creators.py b/test/cim/cim_creators.py index ff72ccb26..88f4df07d 100644 --- a/test/cim/cim_creators.py +++ b/test/cim/cim_creators.py @@ -738,13 +738,17 @@ def create_substation(include_runtime: bool = True): def create_terminal(include_runtime: bool = True): + runtime = { + "traced_phases": builds(TracedPhases) + } if include_runtime else {} return builds( Terminal, **create_ac_dc_terminal(include_runtime), conducting_equipment=sampled_conducting_equipment(include_runtime), connectivity_node=builds(ConnectivityNode, **create_identified_object(include_runtime)), phases=sampled_phase_code(), - sequence_number=integers(min_value=MIN_SEQUENCE_NUMBER, max_value=MAX_SEQUENCE_NUMBER) + sequence_number=integers(min_value=MIN_SEQUENCE_NUMBER, max_value=MAX_SEQUENCE_NUMBER), + **runtime ) @@ -1022,9 +1026,11 @@ def create_power_electronics_wind_unit(include_runtime: bool = True): def create_ac_line_segment(include_runtime: bool = True): + args = create_conductor(include_runtime) + args["terminals"] = lists(builds(Terminal, **create_identified_object(include_runtime)), min_size=1, max_size=2) return builds( AcLineSegment, - **create_conductor(include_runtime), + **args, per_length_impedance=builds(PerLengthSequenceImpedance, **create_identified_object(include_runtime)) ) @@ -1045,20 +1051,24 @@ def create_busbar_section(include_runtime: bool = True): def create_clamp(include_runtime: bool = True): + args = create_conducting_equipment(include_runtime) + args["terminals"] = lists(builds(Terminal, **create_identified_object(include_runtime)), min_size=1, max_size=1) return builds( Clamp, - **create_conducting_equipment(include_runtime), + **args, length_from_terminal_1=floats(min_value=FLOAT_MIN, max_value=FLOAT_MAX), ac_line_segment=builds(AcLineSegment, **create_identified_object(include_runtime)) ) def create_cut(include_runtime: bool = True): + args = create_switch(include_runtime) + args["terminals"] = lists(builds(Terminal, **create_identified_object(include_runtime)), min_size=1, max_size=2) return builds( Cut, - **create_switch(include_runtime), + **args, length_from_terminal_1=floats(min_value=FLOAT_MIN, max_value=FLOAT_MAX), - ac_line_segment=builds(AcLineSegment, **create_identified_object(include_runtime)) + ac_line_segment=builds(AcLineSegment, **create_identified_object(include_runtime)), ) diff --git a/test/cim/iec61970/base/core/test_site.py b/test/cim/iec61970/base/core/test_site.py index daf68563e..f83a5be8c 100644 --- a/test/cim/iec61970/base/core/test_site.py +++ b/test/cim/iec61970/base/core/test_site.py @@ -5,7 +5,7 @@ import pytest from hypothesis import given -from build.lib.zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators +from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators from cim.iec61970.base.core.test_equipment_container import equipment_container_kwargs, verify_equipment_container_constructor_default, \ verify_equipment_container_constructor_kwargs, verify_equipment_container_constructor_args, equipment_container_args from zepben.evolve import Site, TestNetworkBuilder, Equipment, AssignToLvFeeders, LvFeeder diff --git a/test/cim/iec61970/base/core/test_terminal.py b/test/cim/iec61970/base/core/test_terminal.py index 46425c36e..8f0d1c3cb 100644 --- a/test/cim/iec61970/base/core/test_terminal.py +++ b/test/cim/iec61970/base/core/test_terminal.py @@ -18,12 +18,12 @@ "sequence_number": integers(min_value=MIN_32_BIT_INTEGER, max_value=MAX_32_BIT_INTEGER), "normal_feeder_direction": sampled_from(FeederDirection), "current_feeder_direction": sampled_from(FeederDirection), - "traced_phases": builds(TracedPhases), + "traced_phases": builds(TracedPhases, phase_status=integers(min_value=0, max_value=15)), "connectivity_node": builds(ConnectivityNode) } # noinspection PyArgumentList -terminal_args = [*ac_dc_terminal_args, ConductingEquipment(), PhaseCode.XYN, TracedPhases, 1, FeederDirection.UPSTREAM, FeederDirection.DOWNSTREAM, +terminal_args = [*ac_dc_terminal_args, ConductingEquipment(), PhaseCode.XYN, TracedPhases(1), 1, FeederDirection.UPSTREAM, FeederDirection.DOWNSTREAM, ConnectivityNode()] @@ -36,6 +36,7 @@ def test_terminal_constructor_default(): assert t.sequence_number == 0 assert t.normal_feeder_direction == FeederDirection.NONE assert t.current_feeder_direction == FeederDirection.NONE + assert t.traced_phases == TracedPhases() assert not t.connectivity_node @@ -47,6 +48,7 @@ def test_terminal_constructor_kwargs(conducting_equipment, phases, sequence_numb sequence_number=sequence_number, normal_feeder_direction=normal_feeder_direction, current_feeder_direction=current_feeder_direction, + traced_phases=traced_phases, connectivity_node=connectivity_node, **kwargs) @@ -56,6 +58,7 @@ def test_terminal_constructor_kwargs(conducting_equipment, phases, sequence_numb assert t.sequence_number == sequence_number assert t.normal_feeder_direction == normal_feeder_direction assert t.current_feeder_direction == current_feeder_direction + assert t.traced_phases == traced_phases assert t.connectivity_node == connectivity_node diff --git a/test/database/sqlite/network/test_network_database_schema.py b/test/database/sqlite/network/test_network_database_schema.py index 231276469..7b93fc358 100644 --- a/test/database/sqlite/network/test_network_database_schema.py +++ b/test/database/sqlite/network/test_network_database_schema.py @@ -497,7 +497,7 @@ async def test_schema_energy_source(self, energy_source): # Need to apply phases to match after the database load. network_service = SchemaNetworks().network_services_of(EnergySource, energy_source) - await Tracing.set_phases().run(network_service, NetworkStateOperators) + await Tracing.set_phases().run(network_service) await self._validate_schema(network_service) diff --git a/test/services/network/test_data/cuts_and_clamps_network.py b/test/services/network/test_data/cuts_and_clamps_network.py new file mode 100644 index 000000000..14b34ccab --- /dev/null +++ b/test/services/network/test_data/cuts_and_clamps_network.py @@ -0,0 +1,56 @@ +# Copyright 2025 Zeppelin Bend Pty Ltd +# 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/. +from typing import Optional + +from zepben.evolve import NetworkService, TestNetworkBuilder, AcLineSegment, Clamp, Terminal, ConductingEquipment, Cut + + +class CutsAndClampsNetwork: + @staticmethod + def multi_cut_and_clamp_network() -> TestNetworkBuilder: + # + # 2 2 + # c3 2 c7 2 + # 1 c5 1 c9 + # 1 clamp1 1 1 clamp3 1 + # | | | | + # 1 b0 21--*--*1 cut1 2*--*--c1--*--*1 cut2 2*--*--21 b2 2 + # | | | | + # 1 1 clamp2 1 1 clamp4 + # c4 1 c8 1 + # 2 c6 2 c10 + # 2 2 + # + builder = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls() # c1 + .with_clamp(length_from_terminal_1=1.0) # c1-clamp1 + .with_cut(length_from_terminal_1=2.0, is_normally_open=False) # c1-cut1 + .with_clamp(length_from_terminal_1=3.0) # c1-clamp2 + .with_clamp(length_from_terminal_1=4.0) # c1-clamp3 + .with_cut(length_from_terminal_1=5.0, is_normally_open=False) # c1-cut2 + .with_clamp(length_from_terminal_1=6.0) # c1-clamp4 + .to_breaker() # b2 + .from_acls() # c3 + .connect_to('c1-clamp1', from_terminal=1) + .from_acls() # c4 + .connect_to('c1-cut1', from_terminal=1) + .from_acls() # c5 + .connect_to('c1-cut1', to_terminal=2, from_terminal=1) + .from_acls() # c6 + .connect_to('c1-clamp2', from_terminal=1) + .from_acls() # c7 + .connect_to('c1-clamp3', from_terminal=1) + .from_acls() # c8 + .connect_to('c1-cut2', from_terminal=1) + .from_acls() # c9 + .connect_to('c1-cut2', to_terminal=2, from_terminal=1) + .from_acls() # c10 + .connect_to('c1-clamp4', from_terminal=1) + ) + + return builder + + diff --git a/test/services/network/test_data/looping_network.py b/test/services/network/test_data/looping_network.py index 7c825c4f0..db5522bf6 100644 --- a/test/services/network/test_data/looping_network.py +++ b/test/services/network/test_data/looping_network.py @@ -12,77 +12,77 @@ def create_looping_network(): :return: An example network with loops. """ # - # j0 c1 j2 c13 j14 c15 j16 + # j0 ac0 j1 ac1 j2 ac2 j3 # *11------21*21------21*21------21* # 3 2 # 1 1 - # c3 | | c17 + # ac3 | | ac4 # 2 2 - # 1 c20 1 - # j4 *21------21* j21 * b18 (open) + # 1 ac5 1 + # j4 *21------21* j5 * j6 (open) # 3 2 # 1 1 - # c5 | | c19 + # ac6 | | ac7 # 2 2 - # 1 c22 j23 c24 2 - # j6 *21------21*21------21* j25 + # 1 ac8 j8 ac9 2 + # j7 *21------21*21------21* j9 # 3 - # 1 c29 - # | /--21* j30 - # c7 | / 2 + # 1 ac11 + # | /--21* j11 + # ac10 | / 2 # | / 1 - # 2 / | c31 + # 2 / | ac13 # 1 / 2 - # j8 *21 c9 2 c11 - # 31--------21*31------21* j12 - # 1 2 j10 + # j10 *21 ac12 2 ac14 + # 31--------21*31------21* j13 + # 1 2 j12 # \ | - # \ 1 c28 + # \ 1 ac16 # \ 2 - # \--21* j27 - # c26 + # \--21* j14 + # ac15 # return ( TestNetworkBuilder() - .from_junction(nominal_phases=PhaseCode.ABCN, num_terminals=1) # j0 - .to_acls(nominal_phases=PhaseCode.ABCN) # c1 - .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=3) # j2 - .to_acls(nominal_phases=PhaseCode.ABCN) # c3 - .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=3) # j4 - .to_acls(nominal_phases=PhaseCode.ABCN) # c5 - .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=3) # j6 - .to_acls(nominal_phases=PhaseCode.ABCN) # c7 - .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=3) # j8 - .to_acls(nominal_phases=PhaseCode.ABCN) # c9 - .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=3) # j10 - .to_acls(nominal_phases=PhaseCode.ABCN) # c11 - .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=1) # j12 - .branch_from("j2", 2) - .to_acls(nominal_phases=PhaseCode.ABCN) # c13 - .to_junction(nominal_phases=PhaseCode.ABCN) # j14 - .to_acls(nominal_phases=PhaseCode.ABCN) # c15 - .to_junction(nominal_phases=PhaseCode.ABCN) # j16 - .to_acls(nominal_phases=PhaseCode.ABCN) # c17 - .to_breaker(nominal_phases=PhaseCode.ABCN, is_normally_open=True) # b18 - .to_acls(nominal_phases=PhaseCode.ABCN) # c19 + .from_junction(nominal_phases=PhaseCode.ABCN, num_terminals=1, mrid='j0') + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac0') + .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=3, mrid='j1') + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac3') + .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=3, mrid='j4') + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac6') + .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=3, mrid='j7') + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac10') + .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=3, mrid='j10') + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac12') + .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=3, mrid='j12') + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac14') + .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=1, mrid='j13') + .branch_from("j1", 2) + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac1') + .to_junction(nominal_phases=PhaseCode.ABCN, mrid='j2') + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac2') + .to_junction(nominal_phases=PhaseCode.ABCN, mrid='j3') + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac4') + .to_breaker(nominal_phases=PhaseCode.ABCN, is_normally_open=True, is_open=True, mrid='j6') + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac7') .branch_from("j4", 2) - .to_acls(nominal_phases=PhaseCode.ABCN) # c20 - .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=1) # j21 - .branch_from("j6", 2) - .to_acls(nominal_phases=PhaseCode.ABCN) # c22 - .to_junction(nominal_phases=PhaseCode.ABCN) # j23 - .to_acls(nominal_phases=PhaseCode.ABCN) # c24 - .to_junction(nominal_phases=PhaseCode.ABCN) # j25 - .connect("c19", "j25", 2, 2) - .branch_from("j8", 3) - .to_acls(nominal_phases=PhaseCode.ABCN) # c26 - .to_junction(nominal_phases=PhaseCode.ABCN) # j27 - .to_acls(nominal_phases=PhaseCode.ABCN) # c28 - .connect("c28", "j10", 2, 1) - .branch_from("j8", 2) - .to_acls(nominal_phases=PhaseCode.ABCN) # c29 - .to_junction(nominal_phases=PhaseCode.ABCN) # j30 - .to_acls(nominal_phases=PhaseCode.ABCN) # c31 - .connect("c31", "j10", 2, 2) + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac5') + .to_junction(nominal_phases=PhaseCode.ABCN, num_terminals=1, mrid='j5') + .branch_from("j7", 2) + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac8') + .to_junction(nominal_phases=PhaseCode.ABCN, mrid='j8') + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac9') + .to_junction(nominal_phases=PhaseCode.ABCN, mrid='j9') + .connect("ac7", "j9", 2, 2) + .branch_from("j10", 3) + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac15') + .to_junction(nominal_phases=PhaseCode.ABCN, mrid='j14') + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac16') + .connect("ac16", "j12", 2, 1) + .branch_from("j10", 2) + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac11') + .to_junction(nominal_phases=PhaseCode.ABCN, mrid='j11') + .to_acls(nominal_phases=PhaseCode.ABCN, mrid='ac13') + .connect("ac13", "j12", 2, 2) .network ) diff --git a/test/services/network/tracing/feeder/direction_logger.py b/test/services/network/tracing/feeder/direction_logger.py index 2ed3fc6a7..ce0ee6614 100644 --- a/test/services/network/tracing/feeder/direction_logger.py +++ b/test/services/network/tracing/feeder/direction_logger.py @@ -2,11 +2,15 @@ # 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/. +from typing import TypeVar from zepben.evolve import ConductingEquipment, Tracing, Traversal __all__ = ["log_directions"] +T = TypeVar('T') + + from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep @@ -26,6 +30,6 @@ async def log_directions(*conducting_equipment: ConductingEquipment): await trace.run(cond_equip, False) -def _step(step: NetworkTraceStep, _: bool): +def _step(step: NetworkTraceStep[T], _: None): for term in step.path.to_equipment.terminals: print(f"{step.path.to_terminal.conducting_equipment.mrid}-T{term.sequence_number}: {{n:{term.normal_feeder_direction}, c:{term.current_feeder_direction}}}") diff --git a/test/services/network/tracing/feeder/test_set_direction.py b/test/services/network/tracing/feeder/test_set_direction.py index f68739ce9..593ee3eee 100644 --- a/test/services/network/tracing/feeder/test_set_direction.py +++ b/test/services/network/tracing/feeder/test_set_direction.py @@ -1,14 +1,17 @@ -# Copyright 2024 Zeppelin Bend Pty Ltd +# Copyright 2025 Zeppelin Bend Pty Ltd # 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/. + +from typing import Type, Union + import pytest +from services.network.test_data.cuts_and_clamps_network import CutsAndClampsNetwork from services.network.test_data.phase_swap_loop_network import create_phase_swap_loop_network from services.network.tracing.feeder.direction_logger import log_directions from zepben.evolve import FeederDirection, TestNetworkBuilder, SetDirection, PhaseCode, NetworkService, Feeder, Terminal, ConductingEquipment, Substation, \ - NetworkStateOperators, Traversal, StepContext -from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep + NetworkStateOperators, Cut UPSTREAM = FeederDirection.UPSTREAM DOWNSTREAM = FeederDirection.DOWNSTREAM @@ -22,8 +25,7 @@ class TestSetDirection: async def test_set_direction(self): n = create_phase_swap_loop_network() - await self._do_set_direction_trace(n, NetworkStateOperators.NORMAL) - await self._do_set_direction_trace(n, NetworkStateOperators.CURRENT) + await self._do_set_direction_trace(n) self._check_expected_direction(self._get_t(n, "ac_line_segment0", 1), UPSTREAM) self._check_expected_direction(self._get_t(n, "ac_line_segment0", 2), DOWNSTREAM) @@ -205,7 +207,7 @@ async def test_set_direction_in_closed_loop(self): .add_feeder("s0") \ .network # Do not call build as we do not want to trace the directions yet. - await self._do_set_direction_trace(n, NetworkStateOperators.NORMAL) + await self._do_set_direction_trace(n) self._check_expected_direction(self._get_t(n, "s0", 1), DOWNSTREAM) self._check_expected_direction(self._get_t(n, "c1", 1), UPSTREAM) @@ -318,25 +320,25 @@ async def test_dual_path_loop_bottom(self): # | | # \-c10-21 j11 21-c12-/ # - n = TestNetworkBuilder() \ - .from_junction(num_terminals=1) \ - .to_acls() \ - .to_junction(num_terminals=3) \ - .to_acls() \ - .to_junction() \ - .to_acls() \ - .to_junction(num_terminals=3) \ - .to_acls() \ - .to_junction(num_terminals=1) \ - .from_acls() \ - .from_acls() \ - .to_junction() \ - .to_acls() \ - .connect("c9", "j6", 1, 2) \ - .connect("c9", "j2", 2, 2) \ - .connect("c10", "j2", 1, 2) \ - .connect("c12", "j6", 2, 2) \ - .network + n = (TestNetworkBuilder() + .from_junction(num_terminals=1) #j0 + .to_acls() #c1 + .to_junction(num_terminals=3) #j2 + .to_acls() #c3 + .to_junction() #j4 + .to_acls() #c5 + .to_junction(num_terminals=3) #j6 + .to_acls() #c7 + .to_junction(num_terminals=1) #j8 + .from_acls() #c9 + .from_acls() #c10 + .to_junction() #c11 + .to_acls() #c12 + .connect_to('j6', 2) + .connect("c9", "j6", 1, 2) + .connect("c9", "j2", 2, 2) + .connect("c10", "j2", 1, 2) + ).network await SetDirection().run_terminal(self._get_t(n, "j0", 1)) await log_directions(n["j0"]) @@ -454,11 +456,232 @@ async def test_set_direction_doesnt_flow_through_feeder_heads(self): self._check_expected_direction(self._get_t(n, "b2", 1), BOTH) self._check_expected_direction(self._get_t(n, "b2", 2), NONE) + @pytest.mark.asyncio + async def test_set_direction_on_acls_with_cuts_and_clamps_from_acls_end_terminal(self): + n = CutsAndClampsNetwork.multi_cut_and_clamp_network() \ + .add_feeder('b0', 2) \ + .network + + n.get('c1-cut1', Cut).set_normally_open(False) + n.get('c1-cut2', Cut).set_normally_open(True) + + await self._do_set_direction_trace(self._get_t(n, 'b0', 2)) + + self._check_expected_direction(self._get_t(n, 'b0', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp1', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c3', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c3', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c4', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c4', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut1', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut1', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c5', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c5', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp2', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp3', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c8', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c8', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut2', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut2', 2), NONE) + + @pytest.mark.asyncio + async def test_sets_direction_on_acls_with_cuts_and_clamps_fed_from_clamp(self): + n = CutsAndClampsNetwork.multi_cut_and_clamp_network() \ + .add_feeder('c6', 1) \ + .network + + n.get('c1-cut1', Cut).set_normally_open(False) + n.get('c1-cut2', Cut).set_normally_open(True) + + await self._do_set_direction_trace(self._get_t(n, 'c6', 1)) + + self._check_expected_direction(self._get_t(n, 'c6', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c6', 2), NONE) + self._check_expected_direction(self._get_t(n, 'c1-clamp2', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp3', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c7', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c7', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c8', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c8', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut2', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut2', 2), NONE) + self._check_expected_direction(self._get_t(n, 'c5', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c5', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut1', 2), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut1', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c4', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c4', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp1', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c3', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c3', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'b0', 2), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'b0', 1), DOWNSTREAM) + + @pytest.mark.asyncio + async def test_sets_direction_on_acls_with_cuts_and_clamps_fed_from_cut(self): + n = CutsAndClampsNetwork.multi_cut_and_clamp_network() \ + .add_feeder('c5', 1) \ + .network + + n.get('c1-cut1', Cut).set_normally_open(False) + n.get('c1-cut2', Cut).set_normally_open(True) + + await self._do_set_direction_trace(self._get_t(n, 'c5', 1)) + + self._check_expected_direction(self._get_t(n, 'c5', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c5', 2), NONE) + self._check_expected_direction(self._get_t(n, 'c1-cut1', 2), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut1', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c4', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c4', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp1', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c3', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c3', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'b0', 2), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'b0', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp2', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c6', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c6', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp3', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c7', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c7', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c8', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c8', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut2', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut2', 2), NONE) + + @pytest.mark.asyncio + async def test_sets_direction_on_acls_with_cuts_and_clamps_fed_from_both_acls_ends(self): + n = CutsAndClampsNetwork.multi_cut_and_clamp_network() \ + .add_feeder('b0', 2) \ + .add_feeder('b2', 1) \ + .network + + n.get('c1-cut1', Cut).set_normally_open(False) + n.get('c1-cut2', Cut).set_normally_open(False) + + await self._do_set_direction_trace(self._get_t(n, 'b0', 2)) + await self._do_set_direction_trace(self._get_t(n, 'b2', 1)) + + self._check_expected_direction(self._get_t(n, 'b0', 2), BOTH) + self._check_expected_direction(self._get_t(n, 'b0', 1), NONE) + self._check_expected_direction(self._get_t(n, 'c1', 1), BOTH) + self._check_expected_direction(self._get_t(n, 'c1-clamp1', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c3', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c3', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c4', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c4', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut1', 2), BOTH) + self._check_expected_direction(self._get_t(n, 'c1-cut1', 1), BOTH) + self._check_expected_direction(self._get_t(n, 'c5', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c5', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp2', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c6', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c6', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp3', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c7', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c7', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c8', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c8', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut2', 1), BOTH) + self._check_expected_direction(self._get_t(n, 'c1-cut2', 2), BOTH) + self._check_expected_direction(self._get_t(n, 'c9', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c9', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp4', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c10', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c10', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'b2', 1), BOTH) + self._check_expected_direction(self._get_t(n, 'b2', 2), NONE) + + @pytest.mark.asyncio + async def test_sets_direction_on_acls_with_cuts_and_clamps_fed_from_acls_end_and_clamp(self): + n = CutsAndClampsNetwork.multi_cut_and_clamp_network() \ + .add_feeder('b0', 2) \ + .add_feeder('c6', 1) \ + .network + + n.get('c1-cut1', Cut).set_normally_open(False) + n.get('c1-cut2', Cut).set_normally_open(True) + + await self._do_set_direction_trace(self._get_t(n, 'b0', 2)) + await self._do_set_direction_trace(self._get_t(n, 'c6', 1)) + + self._check_expected_direction(self._get_t(n, 'b0', 2), BOTH) + self._check_expected_direction(self._get_t(n, 'b0', 1), NONE) + self._check_expected_direction(self._get_t(n, 'c1', 1), BOTH) + self._check_expected_direction(self._get_t(n, 'c1-clamp1', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c3', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c3', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c4', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c4', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut1', 2), BOTH) + self._check_expected_direction(self._get_t(n, 'c1-cut1', 1), BOTH) + self._check_expected_direction(self._get_t(n, 'c5', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c5', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp2', 1), BOTH) + self._check_expected_direction(self._get_t(n, 'c6', 1), BOTH) + self._check_expected_direction(self._get_t(n, 'c6', 2), NONE) + self._check_expected_direction(self._get_t(n, 'c1-clamp3', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c7', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c7', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c8', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c8', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut2', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut2', 2), NONE) + + @pytest.mark.asyncio + async def test_sets_direction_on_acls_with_cuts_and_clamps_fed_from_acls_clamp_and_cut(self): + n = CutsAndClampsNetwork.multi_cut_and_clamp_network() \ + .add_feeder('c3', 1) \ + .add_feeder('c5', 1) \ + .network + + n.get('c1-cut1', Cut).set_normally_open(False) + n.get('c1-cut2', Cut).set_normally_open(True) + + await self._do_set_direction_trace(self._get_t(n, 'c3', 1)) + await self._do_set_direction_trace(self._get_t(n, 'c5', 1)) + + self._check_expected_direction(self._get_t(n, 'b0', 2), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'b0', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp1', 1), BOTH) + self._check_expected_direction(self._get_t(n, 'c3', 1), BOTH) + self._check_expected_direction(self._get_t(n, 'c3', 2), NONE) + self._check_expected_direction(self._get_t(n, 'c4', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c4', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut1', 2), BOTH) + self._check_expected_direction(self._get_t(n, 'c1-cut1', 1), BOTH) + self._check_expected_direction(self._get_t(n, 'c5', 1), BOTH) + self._check_expected_direction(self._get_t(n, 'c5', 2), NONE) + self._check_expected_direction(self._get_t(n, 'c1-clamp2', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c6', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c6', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-clamp3', 1), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c7', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c7', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c8', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c8', 2), DOWNSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut2', 1), UPSTREAM) + self._check_expected_direction(self._get_t(n, 'c1-cut2', 2), NONE) + + @staticmethod - async def _do_set_direction_trace(n: NetworkService, nso: NetworkStateOperators): - await SetDirection().run(n, network_state_operators=nso) - for it in n.objects(Feeder): - await log_directions(it.normal_head_terminal.conducting_equipment) + async def _do_set_direction_trace(n: Union[NetworkService, Terminal]): + async def _all_nso(start): + for nso in (NetworkStateOperators.NORMAL, NetworkStateOperators.CURRENT): + await SetDirection().run(start, network_state_operators=nso) + + await log_directions(start) + if isinstance(n, NetworkService): + for it in n.objects(Feeder): + if it.normal_head_terminal: + await _all_nso(it.normal_head_terminal) + else: + await _all_nso(n) @staticmethod def _get_t(network: NetworkService, mrid: str, sequence_number: int) -> Terminal: diff --git a/test/services/network/tracing/networktrace/actions/test_equipment_tree_builder.py b/test/services/network/tracing/networktrace/actions/test_equipment_tree_builder.py index 8a961bc80..250bb1cf9 100644 --- a/test/services/network/tracing/networktrace/actions/test_equipment_tree_builder.py +++ b/test/services/network/tracing/networktrace/actions/test_equipment_tree_builder.py @@ -2,12 +2,16 @@ # 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/. -from collections import deque +import pprint +from collections import deque, defaultdict from typing import Optional, List import pytest +from zepben.evolve import downstream, NetworkTraceActionType + from services.network.test_data.looping_network import create_looping_network +from services.network.tracing.feeder.direction_logger import log_directions from zepben.evolve import ConductingEquipment, Tracing, NetworkStateOperators from zepben.evolve.services.network.tracing.networktrace.actions.equipment_tree_builder import EquipmentTreeBuilder from zepben.evolve.services.network.tracing.networktrace.actions.tree_node import TreeNode @@ -16,114 +20,130 @@ @pytest.mark.asyncio async def test_downstream_tree(): n = create_looping_network() + normal = NetworkStateOperators.NORMAL + current = NetworkStateOperators.CURRENT await Tracing.set_phases().run(n) feeder_head = n.get("j0", ConductingEquipment) - await Tracing.set_direction().run_terminal(feeder_head.get_terminal_by_sn(1)) + await Tracing.set_direction().run_terminal(feeder_head, network_state_operators=normal) + await Tracing.set_direction().run_terminal(feeder_head, network_state_operators=current) + await log_directions(n.get('j0', ConductingEquipment)) + + visited_ce = [] - start = n.get("j2", ConductingEquipment) + start = n.get("j1", ConductingEquipment) assert start is not None tree_builder = EquipmentTreeBuilder() - state_operators = NetworkStateOperators.NORMAL - await Tracing.network_trace_branching(network_state_operators=state_operators) \ - .add_condition(state_operators.downstream()) \ + trace = Tracing.network_trace_branching(network_state_operators=normal, action_step_type=NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT) \ + .add_condition(downstream()) \ .add_step_action(tree_builder) \ - .run(start) + .add_step_action(lambda item, context: visited_ce.append(item.path.to_equipment.mrid)) + + await trace.run(start) + + visit_counts = {} + for ce in visited_ce: + if visit_counts.get(ce): + visit_counts[ce] += 1 + else: + visit_counts[ce] = 1 + + pprint.pprint(visit_counts) root = list(tree_builder.roots)[0] assert root is not None - _verify_tree_asset(root, n["j2"], None, [n["c3"], n["c13"]]) + _verify_tree_asset(root, n["j1"], None, [n["ac1"], n["ac3"]]) - test_node = next(iter(root.children)) - _verify_tree_asset(test_node, n["c13"], n["j2"], [n["j14"]]) + test_node = root.children[0] + _verify_tree_asset(test_node, n["ac1"], n["j1"], [n["j2"]]) - test_node = next(iter(test_node.children)) - _verify_tree_asset(test_node, n["j14"], n["c13"], [n["c15"]]) + test_node = test_node.children[0] + _verify_tree_asset(test_node, n["j2"], n["ac1"], [n["ac2"]]) - test_node = next(iter(test_node.children)) - _verify_tree_asset(test_node, n["c15"], n["j14"], [n["j16"]]) + test_node = test_node.children[0] + _verify_tree_asset(test_node, n["ac2"], n["j2"], [n["j3"]]) test_node = next(iter(test_node.children)) - _verify_tree_asset(test_node, n["j16"], n["c15"], [n["c17"]]) + _verify_tree_asset(test_node, n["j3"], n["ac2"], [n["ac4"]]) test_node = next(iter(test_node.children)) - _verify_tree_asset(test_node, n["c17"], n["j16"], [n["b18"]]) + _verify_tree_asset(test_node, n["ac4"], n["j3"], [n["j6"]]) test_node = next(iter(test_node.children)) - _verify_tree_asset(test_node, n["b18"], n["c17"], []) + _verify_tree_asset(test_node, n["j6"], n["ac4"], []) test_node = list(root.children)[1] - _verify_tree_asset(test_node, n["c3"], n["j2"], [n["j4"]]) + _verify_tree_asset(test_node, n["ac3"], n["j1"], [n["j4"]]) test_node = next(iter(test_node.children)) - _verify_tree_asset(test_node, n["j4"], n["c3"], [n["c20"], n["c5"]]) + _verify_tree_asset(test_node, n["j4"], n["ac3"], [n["ac5"], n["ac6"]]) assert len(_find_nodes(root, "j0")) == 0 - assert len(_find_nodes(root, "c1")) == 0 + assert len(_find_nodes(root, "ac0")) == 0 + assert len(_find_nodes(root, "j1")) == 1 + assert len(_find_nodes(root, "ac1")) == 1 assert len(_find_nodes(root, "j2")) == 1 - assert len(_find_nodes(root, "c13")) == 1 - assert len(_find_nodes(root, "j14")) == 1 - assert len(_find_nodes(root, "c15")) == 1 - assert len(_find_nodes(root, "j16")) == 1 - assert len(_find_nodes(root, "c3")) == 1 + assert len(_find_nodes(root, "ac2")) == 1 + assert len(_find_nodes(root, "j3")) == 1 + assert len(_find_nodes(root, "ac3")) == 1 assert len(_find_nodes(root, "j4")) == 1 - assert len(_find_nodes(root, "c17")) == 1 - assert len(_find_nodes(root, "j21")) == 1 - assert len(_find_nodes(root, "c20")) == 1 - assert len(_find_nodes(root, "b18")) == 2 - assert len(_find_nodes(root, "c5")) == 1 - assert len(_find_nodes(root, "j6")) == 1 - assert len(_find_nodes(root, "c19")) == 1 - assert len(_find_nodes(root, "j23")) == 1 - assert len(_find_nodes(root, "c22")) == 1 - assert len(_find_nodes(root, "j25")) == 1 - assert len(_find_nodes(root, "c24")) == 1 + assert len(_find_nodes(root, "ac4")) == 1 + assert len(_find_nodes(root, "j5")) == 1 + assert len(_find_nodes(root, "ac5")) == 1 + assert len(_find_nodes(root, "j6")) == 2 + assert len(_find_nodes(root, "ac6")) == 1 + assert len(_find_nodes(root, "j7")) == 1 + assert len(_find_nodes(root, "ac7")) == 1 assert len(_find_nodes(root, "j8")) == 1 - assert len(_find_nodes(root, "c7")) == 1 - assert len(_find_nodes(root, "j30")) == 3 # j11 java sdk - assert len(_find_nodes(root, "c29")) == 3 # acLineSegment11 java sdk - assert len(_find_nodes(root, "j10")) == 3 - assert len(_find_nodes(root, "c9")) == 4 + assert len(_find_nodes(root, "ac8")) == 1 + assert len(_find_nodes(root, "j9")) == 1 + assert len(_find_nodes(root, "ac9")) == 1 + assert len(_find_nodes(root, "j10")) == 1 + assert len(_find_nodes(root, "ac10")) == 1 + assert len(_find_nodes(root, "j11")) == 3 # j11 java sdk + assert len(_find_nodes(root, "ac11")) == 3 # acLineSegment11 java sdk assert len(_find_nodes(root, "j12")) == 3 - assert len(_find_nodes(root, "c31")) == 3 # acLineSegment13 java jdk - assert len(_find_nodes(root, "j27")) == 4 - assert len(_find_nodes(root, "c11")) == 3 - assert len(_find_nodes(root, "c26")) == 4 - assert len(_find_nodes(root, "c28")) == 4 + assert len(_find_nodes(root, "ac12")) == 4 + assert len(_find_nodes(root, "j13")) == 3 + assert len(_find_nodes(root, "ac13")) == 3 # acLineSegment13 java jdk + assert len(_find_nodes(root, "j14")) == 4 + assert len(_find_nodes(root, "ac14")) == 3 + assert len(_find_nodes(root, "ac15")) == 4 + assert len(_find_nodes(root, "ac16")) == 4 assert _find_node_depths(root, "j0") == [] - assert _find_node_depths(root, "c1") == [] - assert _find_node_depths(root, "j2") == [0] - assert _find_node_depths(root, "c13") == [1] - assert _find_node_depths(root, "j14") == [2] - assert _find_node_depths(root, "c15") == [3] - assert _find_node_depths(root, "j16") == [4] - assert _find_node_depths(root, "c3") == [1] + assert _find_node_depths(root, "ac0") == [] + assert _find_node_depths(root, "j1") == [0] + assert _find_node_depths(root, "ac1") == [1] + assert _find_node_depths(root, "j2") == [2] + assert _find_node_depths(root, "ac2") == [3] + assert _find_node_depths(root, "j3") == [4] + assert _find_node_depths(root, "ac3") == [1] assert _find_node_depths(root, "j4") == [2] - assert _find_node_depths(root, "c17") == [5] - assert _find_node_depths(root, "j21") == [4] - assert _find_node_depths(root, "c20") == [3] - assert _find_node_depths(root, "b18") == [6, 10] - assert _find_node_depths(root, "c5") == [3] - assert _find_node_depths(root, "j6") == [4] - assert _find_node_depths(root, "c19") == [9] - assert _find_node_depths(root, "j23") == [6] - assert _find_node_depths(root, "c22") == [5] - assert _find_node_depths(root, "j25") == [8] - assert _find_node_depths(root, "c24") == [7] + assert _find_node_depths(root, "ac4") == [5] + assert _find_node_depths(root, "j5") == [4] + assert _find_node_depths(root, "ac5") == [3] + assert _find_node_depths(root, "j6") == [6, 10] + assert _find_node_depths(root, "ac6") == [3] + assert _find_node_depths(root, "j7") == [4] + assert _find_node_depths(root, "ac7") == [9] assert _find_node_depths(root, "j8") == [6] - assert _find_node_depths(root, "c7") == [5] - assert _find_node_depths(root, "j30") == [8, 10, 12] - assert _find_node_depths(root, "c29") == [7, 11, 13] - assert _find_node_depths(root, "j10") == [8, 10, 10] - assert _find_node_depths(root, "c9") == [7, 10, 11, 14] - assert _find_node_depths(root, "j12") == [10, 12, 12] - assert _find_node_depths(root, "c31") == [9, 9, 11] - assert _find_node_depths(root, "j27") == [8, 9, 12, 13] - assert _find_node_depths(root, "c11") == [9, 11, 11] - assert _find_node_depths(root, "c26") == [7, 10, 12, 13] - assert _find_node_depths(root, "c28") == [8, 9, 11, 14] + assert _find_node_depths(root, "ac8") == [5] + assert _find_node_depths(root, "j9") == [8] + assert _find_node_depths(root, "ac9") == [7] + assert _find_node_depths(root, "j10") == [6] + assert _find_node_depths(root, "ac10") == [5] + assert _find_node_depths(root, "j11") == [8, 10, 12] + assert _find_node_depths(root, "ac11") == [7, 11, 13] + assert _find_node_depths(root, "j12") == [8, 10, 10] + assert _find_node_depths(root, "ac12") == [7, 10, 11, 14] + assert _find_node_depths(root, "j13") == [10, 12, 12] + assert _find_node_depths(root, "ac13") == [9, 9, 11] + assert _find_node_depths(root, "j14") == [8, 9, 12, 13] + assert _find_node_depths(root, "ac14") == [9, 11, 11] + assert _find_node_depths(root, "ac15") == [7, 10, 12, 13] + assert _find_node_depths(root, "ac16") == [8, 9, 11, 14] def _verify_tree_asset( @@ -141,11 +161,8 @@ def _verify_tree_asset( else: assert tree_node.parent is None - children_nodes = list(tree_node.children) - assert len(children_nodes) == len(expected_children) - - for child in children_nodes: - assert child.identified_object in expected_children + children_nodes = list(c.identified_object for c in tree_node.children) + assert children_nodes == expected_children def _find_nodes(root: TreeNode[ConductingEquipment], asset_id: str) -> List[TreeNode[ConductingEquipment]]: diff --git a/test/services/network/tracing/networktrace/conditions/test_conditions.py b/test/services/network/tracing/networktrace/conditions/test_conditions.py new file mode 100644 index 000000000..b40aee0ac --- /dev/null +++ b/test/services/network/tracing/networktrace/conditions/test_conditions.py @@ -0,0 +1,60 @@ +# Copyright 2025 Zeppelin Bend Pty Ltd +# 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/. +from typing import Optional, Callable + +from zepben.evolve import NetworkStateOperators, FeederDirection, SinglePhaseKind, Switch, PowerTransformer +from zepben.evolve.services.network.tracing.networktrace.conditions.conditions import limit_equipment_steps +from zepben.evolve.services.network.tracing.networktrace.conditions.direction_condition import DirectionCondition +from zepben.evolve.services.network.tracing.networktrace.conditions.equipment_step_limit_condition import EquipmentStepLimitCondition +from zepben.evolve.services.network.tracing.networktrace.conditions.equipment_type_step_limit_condition import EquipmentTypeStepLimitCondition +from zepben.evolve.services.network.tracing.networktrace.conditions.open_condition import OpenCondition + + +class TestCondition: + def test_state_operators_with_direction(self): + state_operators = NetworkStateOperators.NORMAL + condition = state_operators.with_direction(FeederDirection.BOTH) + assert isinstance(condition, DirectionCondition) + assert condition.state_operators is state_operators + assert condition.direction is FeederDirection.BOTH + + def test_state_operators_upstream(self): + state_operators = NetworkStateOperators.NORMAL + condition = state_operators.upstream() + assert isinstance(condition, DirectionCondition) + assert condition.state_operators is state_operators + assert condition.direction is FeederDirection.UPSTREAM + + def test_state_operators_downstream(self): + state_operators = NetworkStateOperators.NORMAL + condition = state_operators.downstream() + assert isinstance(condition, DirectionCondition) + assert condition.state_operators is state_operators + assert condition.direction is FeederDirection.DOWNSTREAM + + def test_stop_at_open(self): + is_open: Callable[[Switch, Optional[SinglePhaseKind]], bool] = Switch.is_open + state_operators = NetworkStateOperators.NORMAL + condition = state_operators.stop_at_open(is_open, SinglePhaseKind.A) + assert isinstance(condition, OpenCondition) + assert condition._phase is SinglePhaseKind.A + + def test_open_operators_stop_at_open(self): + state_operators = NetworkStateOperators.NORMAL + condition = state_operators.stop_at_open(phase=SinglePhaseKind.A) + assert isinstance(condition, OpenCondition) + assert condition._is_open == state_operators.is_open + assert condition._phase is SinglePhaseKind.A + + def test_limit_equipment_steps(self): + condition = limit_equipment_steps(1) + assert isinstance(condition, EquipmentStepLimitCondition) + assert condition.limit == 1 + + def test_limit_equipment_type_steps(self): + condition = limit_equipment_steps(1, PowerTransformer) + assert isinstance(condition, EquipmentTypeStepLimitCondition) + assert condition.limit == 1 + assert condition.equipment_type is PowerTransformer diff --git a/test/services/network/tracing/networktrace/conditions/test_direction_condition.py b/test/services/network/tracing/networktrace/conditions/test_direction_condition.py index 14b1c58b1..96266d3a3 100644 --- a/test/services/network/tracing/networktrace/conditions/test_direction_condition.py +++ b/test/services/network/tracing/networktrace/conditions/test_direction_condition.py @@ -5,12 +5,16 @@ from typing import Tuple from unittest.mock import MagicMock -from zepben.evolve import FeederDirection, NetworkTraceStep, Terminal +import pytest + +from zepben.evolve import NetworkStateOperators, FeederDirection, NetworkTraceStep, Terminal, Junction +from services.network.test_data.cuts_and_clamps_network import CutsAndClampsNetwork from zepben.evolve.services.network.tracing.networktrace.conditions.direction_condition import DirectionCondition class TestDirectionCondition: - def test_should_queue(self): + + def test_should_queue_for_non_cut_or_clamp_path(self): traced_internally = True _terminal_should_queue((FeederDirection.NONE, FeederDirection.NONE, traced_internally), True) _terminal_should_queue((FeederDirection.NONE, FeederDirection.UPSTREAM, traced_internally), False) @@ -61,7 +65,7 @@ def test_should_queue(self): _terminal_should_queue((FeederDirection.BOTH, FeederDirection.NONE, traced_internally), False) _terminal_should_queue((FeederDirection.BOTH, FeederDirection.CONNECTOR, traced_internally), True) - def test_should_queue_start_item(self): + def test_should_queue_start_item_for_non_cut_or_clamp(self): _start_terminal_should_queue((FeederDirection.NONE, FeederDirection.NONE), True) _start_terminal_should_queue((FeederDirection.NONE, FeederDirection.UPSTREAM), False) _start_terminal_should_queue((FeederDirection.NONE, FeederDirection.DOWNSTREAM), False) @@ -86,25 +90,119 @@ def test_should_queue_start_item(self): _start_terminal_should_queue((FeederDirection.BOTH, FeederDirection.BOTH), True) _start_terminal_should_queue((FeederDirection.BOTH, FeederDirection.CONNECTOR), True) + @pytest.mark.asyncio + async def test_cuts_queue_when_direction_set_from_segment_end(self): + network = await CutsAndClampsNetwork.multi_cut_and_clamp_network() \ + .add_feeder('b0', 2) \ + .build() + + c1 = network['c1'] + cut1 = network['c1-cut1'] + c4 = network['c4'] + c5 = network['c5'] + + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(cut1[1], cut1[2])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(cut1[1], cut1[1])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(cut1[2], cut1[2])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(c1[1], cut1[1])), True) + + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(cut1[1], cut1[1])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(cut1[2], cut1[2])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(cut1[2], cut1[1])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(c4[1], cut1[1])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(c5[1], cut1[2])), True) + + @pytest.mark.asyncio + async def test_cuts_queue_when_direction_set_from_clamp(self): + network = await CutsAndClampsNetwork.multi_cut_and_clamp_network() \ + .add_feeder('c3', 1) \ + .build() + + c1 = network['c1'] + clamp1 = network['c1-clamp1'] + cut1 = network['c1-cut1'] + c4 = network['c4'] + c5 = network['c5'] + + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(cut1[1], cut1[1])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(cut1[2], cut1[2])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(cut1[1], cut1[2])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(clamp1[1], cut1[1], c1)), True) + + _should_queue((FeederDirection.UPSTREAM, NetworkTraceStep.Path(cut1[1], cut1[1])), True) + _should_queue((FeederDirection.UPSTREAM, NetworkTraceStep.Path(cut1[2], cut1[2])), False) + _should_queue((FeederDirection.UPSTREAM, NetworkTraceStep.Path(cut1[2], cut1[1])), True) + _should_queue((FeederDirection.UPSTREAM, NetworkTraceStep.Path(c5[1], cut1[2])), True) + _should_queue((FeederDirection.UPSTREAM, NetworkTraceStep.Path(c4[1], cut1[1])), True) + + @pytest.mark.asyncio + async def test_cuts_queue_when_direction_set_from_cut(self): + network = await CutsAndClampsNetwork.multi_cut_and_clamp_network() \ + .add_feeder('c4', 1) \ + .build() + + cut1 = network['c1-cut1'] + c4 = network['c4'] + c5 = network['c5'] + + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(cut1[1], cut1[1])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(cut1[2], cut1[2])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(cut1[1], cut1[2])), True) + _should_queue((FeederDirection.DOWNSTREAM, NetworkTraceStep.Path(c4[1], cut1[1])), True) + + _should_queue((FeederDirection.UPSTREAM, NetworkTraceStep.Path(cut1[1], cut1[1])), True) + _should_queue((FeederDirection.UPSTREAM, NetworkTraceStep.Path(cut1[2], cut1[2])), False) + _should_queue((FeederDirection.UPSTREAM, NetworkTraceStep.Path(cut1[2], cut1[1])), True) + _should_queue((FeederDirection.UPSTREAM, NetworkTraceStep.Path(c5[1], cut1[2])), True) + + def test_does_not_support_connector_conditions(self): + with pytest.raises(ValueError): + DirectionCondition(FeederDirection.CONNECTOR, NetworkStateOperators.NORMAL) + def _terminal_should_queue(condition: Tuple[FeederDirection, FeederDirection, bool], expected): direction, to_direction, traced_internally = condition next_path = MagicMock(spec=NetworkTraceStep.Path)() next_path.traced_internally = traced_internally next_path.to_terminal = Terminal() + next_path.to_equipment = Junction() + next_path.did_traverse_ac_line_segment = False next_item = NetworkTraceStep(next_path, 0, 0, None) - result = DirectionCondition(direction, lambda terminal: to_direction).should_queue(next_item, None, None, None) + state_operators = MagicMock(NetworkStateOperators.NORMAL) + state_operators.get_direction = lambda t: to_direction + + result = DirectionCondition(direction, state_operators).should_queue(next_item, None, None, None) assert result == expected def _start_terminal_should_queue(condition: Tuple[FeederDirection, FeederDirection], expected): direction, to_direction = condition - next_path = MagicMock(spec=NetworkTraceStep.Path)() + next_path = MagicMock(spec=NetworkTraceStep.Path) + next_path.configure_mock( + to_terminal=Terminal(), + to_equipment=Junction() + ) next_path.to_terminal = Terminal() + next_path.to_equipment = Junction() + next_path.did_traverse_ac_line_segment = False next_item = NetworkTraceStep(next_path, 0, 0, None) - result = DirectionCondition(direction, lambda terminal: to_direction).should_queue_start_item(next_item) + state_operators = MagicMock(NetworkStateOperators.NORMAL) + state_operators.get_direction = lambda t: to_direction + + result = DirectionCondition(direction, state_operators).should_queue_start_item(next_item) assert result == expected + +def _should_queue(condition: Tuple[FeederDirection, NetworkTraceStep.Path], expected: bool): + direction, path = condition + next_step = MagicMock(spec=NetworkTraceStep) + next_step.configure_mock( + path=path + ) + should_queue = DirectionCondition(direction, NetworkStateOperators.NORMAL).should_queue(next_step, None, None, None) + print(f'direction: {direction}') + print(f'path: internal: {path.traced_internally}\n from: {path.from_terminal}\n to: {path.to_terminal}\n') + assert should_queue == expected diff --git a/test/services/network/tracing/networktrace/conditions/test_open_condition.py b/test/services/network/tracing/networktrace/conditions/test_open_condition.py index 426a5d002..ca7a9ebe6 100644 --- a/test/services/network/tracing/networktrace/conditions/test_open_condition.py +++ b/test/services/network/tracing/networktrace/conditions/test_open_condition.py @@ -31,16 +31,19 @@ def should_queue_params(next_step, next_context=None, current_step=None, current ) -> (NetworkTraceStep, StepContext, NetworkTraceStep, StepContext): return next_step, next_context or MagicMock(), current_step or MagicMock(), current_context or MagicMock() +def _is_open(switch: Switch, phase: SinglePhaseKind) -> bool: + pass + class TestOpenCondition: def test_always_queues_external_steps(self): - is_open = Callable[[Switch, SinglePhaseKind], bool] + is_open = _is_open spk = MagicMock(spec=SinglePhaseKind) next_step = mock_nts(step_type=NetworkTraceStep.Type.EXTERNAL) assert OpenCondition(is_open, spk).should_queue(*should_queue_params(next_step)) def test_always_queues_non_switch_equipment(self): - is_open = Callable[[Switch, SinglePhaseKind], bool] + is_open = _is_open spk = MagicMock(spec=SinglePhaseKind) next_path = mock_nts_path(to_equipment=MagicMock(spec=ConductingEquipment)) diff --git a/test/services/network/tracing/networktrace/test_network_trace.py b/test/services/network/tracing/networktrace/test_network_trace.py index 733a02847..3967d64ae 100644 --- a/test/services/network/tracing/networktrace/test_network_trace.py +++ b/test/services/network/tracing/networktrace/test_network_trace.py @@ -2,27 +2,226 @@ # 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/. +import os +import sys +DEFAULT_RECURSION_LIMIT = sys.getrecursionlimit() + +from typing import List, Set + import pytest +from services.network.tracing.networktrace.test_network_trace_step_path_provider import PathTerminal, _verify_paths +from zepben.evolve import AcLineSegment, Clamp, Terminal, NetworkTraceStep, Cut, ConductingEquipment, TraversalQueue, Junction, ngen, NetworkTraceActionType from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing from zepben.evolve.testing.test_network_builder import TestNetworkBuilder +Terminal.__add__ = PathTerminal.__add__ +Terminal.__sub__ = PathTerminal.__sub__ + class TestNetworkTrace: - @pytest.mark.skip @pytest.mark.asyncio - async def test_can_run_large_branching_traces(self): - builder = TestNetworkBuilder() - network = builder.network + async def test_add_start_clamp_terminal_as_traversed_segment_path(self): + trace = Tracing.network_trace() + segment = AcLineSegment() + clamp = Clamp().add_terminal(Terminal()) + segment.add_clamp(clamp) + + trace.add_start_item(clamp[1]) + assert trace.start_items[0].path == clamp[1] - clamp[1] + + @pytest.mark.asyncio + def test_adds_start_whole_clamp_as_not_traversed_segment_path(self): + trace = Tracing.network_trace() + segment = AcLineSegment() + clamp = Clamp().add_terminal(Terminal()) + segment.add_clamp(clamp) + + trace.add_start_item(clamp) + _verify_paths(ngen([trace.start_items[0].path]), (clamp[1] + clamp[1], )) + + @pytest.mark.asyncio + def test_adds_start_AcLineSegment_terminals_cut_terminals_and_clamp_terminals_as_traversed_segment(self): + trace = Tracing.network_trace() + segment = AcLineSegment() \ + .add_terminal(Terminal()) \ + .add_terminal(Terminal()) + + clamp1 = Clamp() \ + .add_terminal(Terminal()) + segment.add_clamp(clamp1) + + clamp2 = Clamp() \ + .add_terminal(Terminal()) + segment.add_clamp(clamp2) + + cut1 = Cut() \ + .add_terminal(Terminal()) \ + .add_terminal(Terminal()) + segment.add_cut(cut1) + + cut2 = Cut() \ + .add_terminal(Terminal()) \ + .add_terminal(Terminal()) + segment.add_cut(cut2) + + trace.add_start_item(segment) + + _verify_paths((it.path for it in trace.start_items), ( + segment[1] - segment[1], + segment[2] - segment[2], + clamp1[1] - clamp1[1], + clamp2[1] - clamp2[1], + cut1[1] - cut1[1], + cut1[2] - cut1[2], + cut2[1] - cut2[1], + cut2[2] - cut2[2])) + + @pytest.mark.asyncio + async def test_doesnt_bypass_stop_conditions_with_multiple_branches_in_equipment_traces_loop(self): + # + # /--21--c1--21 + # c0 j2 21--c3--2 + # \--12--c4--13 + # + ns = (TestNetworkBuilder() + .from_acls() # c0 + .to_acls() # c1 + .to_junction(num_terminals=3) # j2 + .branch_from('j2', 2) + .to_acls() # c3 + .branch_from('j2', 3) + .to_acls() # c4 + .connect('c4', 'c0', 2, 1) + ).network + + stepped_on: List[str] = [] + await Tracing.network_trace() \ + .add_stop_condition(lambda step, _: step.path.to_equipment.mrid == 'j2') \ + .add_step_action(lambda step, _: stepped_on.append(step.path.to_equipment.mrid)) \ + .run(ns.get('c0', ConductingEquipment)) + + assert stepped_on == ['c0', 'c1', 'j2', 'c4'] + + @pytest.mark.asyncio + async def test_breadth_first_queue_supports_multiple_start_items(self): + # + # 1--c1--21--c2--2 + # 2 1 + # j0 j3 + # 1 2 + # 2--c5--12--c4--1 + # + ns = (TestNetworkBuilder() + .from_junction() # j0 + .to_acls() # c1 + .to_acls() # c2 + .to_junction() # j3 + .to_acls() # c4 + .to_acls() # c5 + .connect('c5', 'j0', 2, 1) + ).network + + steps: List[NetworkTraceStep] = [] + await Tracing.network_trace(queue=TraversalQueue.breadth_first()) \ + .add_step_action(lambda step, _: steps.append(step)) \ + .run(ns.get('j0', Junction)) + + assert list(map(lambda it: (it.num_equipment_steps, it.path.to_equipment.mrid), steps)) \ + == [(0, 'j0'), + (1, 'c5'), + (1, 'c1'), + (2, 'c4'), + (2, 'c2'), + (3, 'j3')] + + @pytest.mark.asyncio + async def test_can_stop_on_start_item_when_running_from_conducting_equipment(self): + # + # 1 b0 21--c1--2 + # + ns = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls() # c1 + ).network + + steps: List[NetworkTraceStep] = [] + await Tracing.network_trace() \ + .add_step_action(lambda step, _: steps.append(step)) \ + .add_stop_condition(lambda step, _: True) \ + .run(ns.get('b0', ConductingEquipment)) + + assert list(map(lambda it: (it.num_equipment_steps, it.path.to_equipment.mrid), steps)) \ + == [(0, 'b0')] + + @pytest.mark.asyncio + async def test_can_Stop_on_start_item_when_running_from_conducting_equipment_branching(self): + # + # 1 b0 21--c1--2 + # 1 + # \--c2--2 + # + ns = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls() # c1 + .branch_from('b0') + .to_acls() # c2 + ).network + + steps: Set[NetworkTraceStep] = set() + await Tracing.network_trace_branching() \ + .add_step_action(lambda step, _: steps.add(step)) \ + .add_stop_condition(lambda step, _: True) \ + .run(ns.get('b0', ConductingEquipment)) + + assert set(map(lambda it: (it.num_equipment_steps, it.path.to_equipment.mrid), steps)) \ + == {(0, 'b0')} + + if 'TOX_ENV_NAME' not in os.environ: # Skips the test during tox runs as variable hardware will affect speed + @pytest.mark.asyncio + async def test_can_run_large_branching_traces(self): + try: + sys.setrecursionlimit(100000) # need to bump this for this test, we're going 1000+ recursive calls deep + + builder = TestNetworkBuilder() + network = builder.network + + builder.from_junction(num_terminals=1) \ + .to_acls() + + for i in range(500): + builder.to_junction(mrid=f'junc-{i}', num_terminals=3) \ + .to_acls(mrid=f'acls-{i}-top') \ + .from_acls(mrid=f'acls-{i}-bottom') \ + .connect(f'junc-{i}', f'acls-{i}-bottom', 2, 1) + + await Tracing.network_trace_branching().run(network['j0'].get_terminal_by_sn(1)) + + except Exception as e: + sys.setrecursionlimit(1000) # back to default + raise e + + @pytest.mark.asyncio + async def test_multiple_start_items_can_stop_on_start_doesnt_prevent_stop_checks_when_visiting_via_loop(self): + ns = (TestNetworkBuilder() + .from_acls() # c0 + .to_acls() # c1 + .to_acls() # c2 + .connect_to('c0') + ).network + + stop_checks: List[str] = [] + steps: List[str] = [] - builder.from_junction(num_terminals=1) \ - .to_acls() + def stop_condition(item, _): + stop_checks.append(item.path.to_terminal.mrid) + return item.path.to_equipment.mrid == 'c1' - for i in range(250): - builder.to_junction(mrid=f'junc-{i}', num_terminals=3) \ - .to_acls(mrid=f'acls-{i}-top') \ - .from_acls(mrid=f'acls-{i}-bottom') \ - .connect(f'junc-{i}', f'acls-{i}-bottom', 2, 1) + trace = Tracing.network_trace(action_step_type=NetworkTraceActionType.ALL_STEPS) \ + .add_stop_condition(stop_condition) \ + .if_not_stopping(lambda item, _: steps.append(item.path.to_terminal.mrid)) + await trace.run(ns.get('c1', ConductingEquipment), can_stop_on_start_item=False) - await Tracing.network_trace_branching().run(network['j0'].get_terminal_by_sn(1)) \ No newline at end of file + assert stop_checks == ['c2-t1', 'c2-t2', 'c0-t1', 'c0-t2', 'c1-t1'] + assert steps == ['c1-t2', 'c2-t1', 'c2-t2', 'c0-t1', 'c0-t2'] diff --git a/test/services/network/tracing/networktrace/test_network_trace_queue_next.py b/test/services/network/tracing/networktrace/test_network_trace_queue_next.py new file mode 100644 index 000000000..7f9c29180 --- /dev/null +++ b/test/services/network/tracing/networktrace/test_network_trace_queue_next.py @@ -0,0 +1,132 @@ +# Copyright 2025 Zeppelin Bend Pty Ltd +# 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/. + +from typing import TypeVar, List +from unittest.mock import MagicMock + +import pytest + +from services.network.tracing.networktrace.util import mock_nts_path, mock_nts, mock_ctx +from zepben.evolve import ComputeData, NetworkTraceStep, ngen, NetworkStateOperators +from zepben.evolve.services.network.tracing.networktrace.network_trace_queue_next import NetworkTraceQueueNext + +T = TypeVar('T') + + +class Queuer: + def __init__(self): + self.queued: List[NetworkTraceStep[T]] = [] + + def __call__(self, step: NetworkTraceStep[T]) -> bool: + try: + self.queued.append(step) + return True + except: + return False + + +class TestNetworkTraceQueueNext: + + @pytest.fixture(autouse=True) + def setup_method(self): + self.state_operators = MagicMock(NetworkStateOperators) + self.data_computer = MagicMock(ComputeData) + self.queuer = Queuer() + self.branching_queuer = Queuer() + yield + + def test_queues_next_basic(self): + queue_next = NetworkTraceQueueNext.Basic(self.state_operators, self.data_computer) + + seed_path = mock_nts_path() + seed_step = mock_nts(seed_path, 3, 1) + seed_step.configure_mock( + num_terminal_steps=3, + num_equipment_steps=1, + path = seed_path + ) + + + seed_context = mock_ctx() + + next_path_1 = mock_nts_path(traced_internally=False) + next_path_2 = mock_nts_path(traced_internally=True) + + self.state_operators.next_paths = lambda seed_path: ngen((next_path_1, next_path_2)) + + def mock_computer(seed_step, seed_context, path): + if path is next_path_1: + return "Foo" + elif path is next_path_2: + return "Bar" + + self.data_computer.compute_next = mock_computer + + queue_next.accept(seed_step, seed_context, self.queuer) + + assert len(self.queuer.queued) == 2 + + _assert_step_equal(self.queuer.queued[0], next_path_1, "Foo", 4, 2) + _assert_step_equal(self.queuer.queued[1], next_path_2, "Bar", 4, 1) + + def test_calls_branching_queuer_when_queing_more_then_1_path_on_branching_queue_next(self): + queue_next = NetworkTraceQueueNext.Branching(self.state_operators, self.data_computer) + + seed_path = mock_nts_path() + seed_step = mock_nts(seed_path, 3, 1) + + seed_context = mock_ctx() + + next_path_1 = mock_nts_path(traced_internally=False) + next_path_2 = mock_nts_path(traced_internally=True) + + self.state_operators.next_paths = lambda seed_path: ngen((next_path_1, next_path_2)) + + def mock_computer(seed_step, seed_context, path): + if path is next_path_1: + return "Foo" + elif path is next_path_2: + return "Bar" + + self.data_computer.compute_next = mock_computer + + queue_next.accept(seed_step, seed_context, self.queuer, self.branching_queuer) + + assert len(self.queuer.queued) == 0 + assert len(self.branching_queuer.queued) == 2 + + _assert_step_equal(self.branching_queuer.queued[0], next_path_1, "Foo", 4, 2) + _assert_step_equal(self.branching_queuer.queued[1], next_path_2, "Bar", 4, 1) + + def test_calls_straight_queuer_when_queuing_a_single_path_on_branching_queue_next(self): + queue_next = NetworkTraceQueueNext.Branching(self.state_operators, self.data_computer) + + seed_path = mock_nts_path() + seed_step = mock_nts(seed_path, 3, 1) + + seed_context = mock_ctx() + + next_path_1 = mock_nts_path(traced_internally=False) + + self.state_operators.next_paths = lambda seed_path: ngen([next_path_1]) + + def mock_computer(seed_step, seed_context, path): + if path is next_path_1: + return "Foo" + + self.data_computer.compute_next = mock_computer + + queue_next.accept(seed_step, seed_context, self.queuer, self.branching_queuer) + + assert len(self.queuer.queued) == 1 + assert len(self.branching_queuer.queued) == 0 + + _assert_step_equal(self.queuer.queued[0], next_path_1, "Foo", 4, 2) + +def _assert_step_equal(step: NetworkTraceStep, path: NetworkTraceStep.Path, data, num_term_step, num_equip_step): + assert step.path is path + assert step.data == data + assert step.num_terminal_steps == num_term_step + assert step.num_equipment_steps == num_equip_step diff --git a/test/services/network/tracing/networktrace/test_network_trace_step_path_provider.py b/test/services/network/tracing/networktrace/test_network_trace_step_path_provider.py new file mode 100644 index 000000000..5886053e6 --- /dev/null +++ b/test/services/network/tracing/networktrace/test_network_trace_step_path_provider.py @@ -0,0 +1,837 @@ +# Copyright 2025 Zeppelin Bend Pty Ltd +# 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/. +from typing import Generator, Iterable + +import pytest +from pytest_subtests.plugin import subtests + +from services.network.test_data.cuts_and_clamps_network import CutsAndClampsNetwork +from zepben.evolve.model.cim.iec61970.base.core.phase_code import PhaseCode +from zepben.evolve.model.cim.iec61970.base.wires.single_phase_kind import SinglePhaseKind +from zepben.evolve.services.network.network_service import NetworkService +from zepben.evolve import NetworkStateOperators, TestNetworkBuilder, NetworkTraceStep, Terminal, NominalPhasePath, Breaker, AcLineSegment, Clamp, Cut, \ + ConductingEquipment +from zepben.evolve.services.network.tracing.networktrace.network_trace_step_path_provider import NetworkTraceStepPathProvider + +class PathTerminal(Terminal): + def __add__(self, other: Terminal) -> NetworkTraceStep.Path: + """ + allows shorthand notation to create a NetworkTraceStep.Path between 2 terminals. Eg: j0[1]+c1[1] + """ + return NetworkTraceStep.Path(self, other, None) + + def __sub__(self, other: Terminal) -> NetworkTraceStep.Path: + """ + allows shorthand notation to create a NetworkTraceStep.Path that traversed an AcLineSegment betweenm 2 terminals. Eg: c1[1]-clamp1[1] + """ + def traversed_ce(ce): + if isinstance(ce, AcLineSegment): + return ce + elif isinstance(ce, (Clamp, Cut)): + return ce.ac_line_segment + else: + raise TypeError('Did not traverse') + return NetworkTraceStep.Path(self, other, traversed_ce(self.conducting_equipment)) + + +@pytest.fixture(scope="function", autouse=True) +def setup_class(): + """override `Terminal.__add__` to make test writing more convenient""" + Terminal.__add__ = PathTerminal.__add__ + Terminal.__sub__ = PathTerminal.__sub__ + yield + + # delete the methods when were done, so we don't modify global state between tests + delattr(Terminal, '__add__') + delattr(Terminal, '__sub__') + + +SPK = SinglePhaseKind + +class TestNetworkTraceStepPathProvider: + + path_provider = NetworkTraceStepPathProvider(NetworkStateOperators.NORMAL) + + def test_current_external_path_steps_internally(self): + # + # 2 + # 1--c0--2 1 j1 + # 3 + # + network = (TestNetworkBuilder() + .from_acls() # c0 + .to_junction(num_terminals=3) # j1 + ).network + + c0 = network['c0'] + j1 = network['j1'] + + current_path = c0[2] + j1[1] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (j1[1] + j1[2], j1[1] + j1[3])) + + def test_current_internal_path_steps_externally(self): + # + # 1 j0 21--c1--2 + # 1 + # c2 + # 2 + # + network = (TestNetworkBuilder() + .from_junction() # j0 + .to_acls() # c1 + .from_acls() # c2 + .connect('j0', 'c2', 2, 1) + ).network + + j0 = network['j0'] + c1 = network['c1'] + c2 = network['c2'] + + current_path = j0[1] + j0[2] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (j0[2] + c1[1], j0[2] + c2[1])) + + def test_only_steps_to_in_service_equipment(self): + # + # 1 j0 21--c1--2 + # 1 + # c2 (not in service) + # 2 + # + network = (TestNetworkBuilder() + .from_junction() # j0 + .to_acls() # c1 + .from_acls() # c2 + .connect('j0', 'c2', 2, 1) + ).network + + network['c2'].normally_in_service = False + + j0 = network['j0'] + c1 = network['c1'] + + current_path = j0[1] + j0[2] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (j0[2] + c1[1], ) ) + + def test_only_includes_followed_phases(self): + # + # 2 (A) + # 1--c0--21 tx1 3 (B) + # 4 (C) + # + network = (TestNetworkBuilder() + .from_acls() # c0 + .to_power_transformer([PhaseCode.ABC, PhaseCode.A, PhaseCode.B, PhaseCode.C]) + ).network + + c0 = network['c0'] + tx1 = network['tx1'] + + current_path = NetworkTraceStep.Path(c0[2], tx1[1], None, {NominalPhasePath(SPK.A, SPK.A), NominalPhasePath(SPK.B, SPK.B)}) + next_paths = self.path_provider.next_paths(current_path) + + # Should not contain tx1-t4 because its not in the phase paths + _verify_paths(next_paths, [ + NetworkTraceStep.Path(tx1[1], tx1[2], None, {NominalPhasePath(SPK.A, SPK.A)}), + NetworkTraceStep.Path(tx1[1], tx1[3], None, {NominalPhasePath(SPK.B, SPK.B)})]) + + def test_stepping_externally_to_connectivity_node_with_busbars_only_goes_to_busbars(self): + network = self._busbar_network() + + b0 = network['b0'] + bbs1 = network['bbs1'] + bbs2 = network['bbs2'] + + current_path = b0[1] + b0[2] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (b0[2] + bbs1[1], b0[2] + bbs2[1])) + + def test_steppiong_externally_from_busbars_does_not_step_to_busbars_or_original_from_terminal(self): + network = self._busbar_network() + + bbs1 = network['bbs1'] + b0= network['b0'] + b3 = network['b3'] + b4 = network['b4'] + b5 = network['b5'] + b6 = network['b6'] + + current_path = b0[2] + bbs1[1] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (bbs1[1] + b3[1], bbs1[1] + b4[1], bbs1[1] + b5[1], bbs1[1] + b6[1])) + + def test_traversing_segment_with_clamps_from_t1_includes_all_clamp_steps(self): + network = self._acls_with_clamps_network() + + breaker = network['b0'] + segment: AcLineSegment = network['c1'] + clamp1 = network['c1-clamp1'] + clamp2 = network['c1-clamp2'] + + current_path = breaker[2] + segment[1] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (segment[1] - clamp1[1], segment[1] - clamp2[1], segment[1] - segment[2])) + + def test_traversing_segment_with_clamps_from_t2_includes_all_clamp_steps(self): + network = self._acls_with_clamps_network() + + breaker = network['b2'] + segment: AcLineSegment = network['c1'] + clamp1 = network['c1-clamp1'] + clamp2 = network['c1-clamp2'] + + current_path = breaker[1] + segment[2] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (segment[2] - clamp2[1], segment[2] - clamp1[1], segment[2] - segment[1])) + + def test_non_traverse_step_to_segment_t1_traverses_towards_t2_stopping_at_cut(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + b0 = network['b0'] + segment = network['c1'] + clamp1 = network['c1-clamp1'] + cut1 = network['c1-cut1'] + + current_path = b0[2] + segment[1] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (segment[1] - clamp1[1], segment[1] - cut1[1])) + + def test_non_traverse_step_to_segment_t2_traverses_towards_t1_stopping_at_cut(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + b2 = network['b2'] + segment = network['c1'] + clamp4 = network['c1-clamp4'] + cut2 = network['c1-cut2'] + + current_path = b2[1] + segment[2] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (segment[2] - clamp4[1], segment[2] - cut2[2])) + + def test_traverse_step_to_cut_t1_steps_externally_and_across_cut(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + segment = network['c1'] + cut1 = network['c1-cut1'] + c4 = network['c4'] + + current_path = segment[1] - cut1[1] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (cut1[1] + cut1[2], cut1[1] + c4[1])) + + def test_traverse_step_to_cut_t2_steps_externally_and_across_cut(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + segment = network['c1'] + cut2 = network['c1-cut2'] + c9 = network['c9'] + + current_path = segment[2] - cut2[2] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (cut2[2] + cut2[1], cut2[2] + c9[1])) + + def test_non_traverse_step_to_cut_t1_traverses_segment_towards_t1_and_internally_through_cut_to_t2(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + segment = network['c1'] + clamp1 = network['c1-clamp1'] + cut1 = network['c1-cut1'] + c4 = network['c4'] + + current_path = c4[1] + cut1[1] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (cut1[1] - clamp1[1], cut1[1] - segment[1], cut1[1] + cut1[2])) + + def test_non_traverse_step_to_cut_t2_traverses_segment_towards_t2_and_internally_through_cut_to_t1(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + segment = network['c1'] + clamp4 = network['c1-clamp4'] + cut2 = network['c1-cut2'] + c9 = network['c9'] + + current_path = c9[1] + cut2[2] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (cut2[2] - clamp4[1], cut2[2] - segment[2], cut2[2] + cut2[1])) + + def test_non_traverse_step_to_clamp_traverses_segment_in_both_directions(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + segment = network['c1'] + clamp1 = network['c1-clamp1'] + cut1 = network['c1-cut1'] + c3 = network['c3'] + + current_path = c3[1] + clamp1[1] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (clamp1[1] - segment[1], clamp1[1] - cut1[1])) + + def test_traverse_step_to_clamp_traces_externally_and_does_not_traverse_back_along_segment(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + segment = network['c1'] + clamp1 = network['c1-clamp1'] + c3 = network['c3'] + + current_path = segment[1] - clamp1[1] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (clamp1[1] + c3[1], )) + + def test_non_traverse_step_to_clamp_between_cuts_traverses_segment_both_ways_stopping_at_cuts(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + c6 = network['c6'] + clamp2 = network['c1-clamp2'] + clamp3 = network['c1-clamp3'] + cut1 = network['c1-cut1'] + cut2 = network['c1-cut2'] + + current_path = c6[1] + clamp2[1] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (clamp2[1] - cut1[2], clamp2[1] - clamp3[1], clamp2[1] - cut2[1])) + + def test_non_traverse_external_step_to_cut_t2_between_cuts_traverses_segment_towards_t2_stopping_at_next_cut_and_steps_internally_to_cut_t1(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + c5 = network['c5'] + clamp2 = network['c1-clamp2'] + clamp3 = network['c1-clamp3'] + cut1 = network['c1-cut1'] + cut2 = network['c1-cut2'] + + current_path = c5[1] + cut1[2] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (cut1[2] - clamp2[1], cut1[2] - clamp3[1], cut1[2] - cut2[1], cut1[2] + cut1[1])) + + def test_non_traverse_external_step_to_cut_t1_between_cuts_traverses_segment_towards_t1_stopping_at_next_cut_and_steps_internally_to_cut_t2(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + c8 = network['c8'] + clamp2 = network['c1-clamp2'] + clamp3 = network['c1-clamp3'] + cut1 = network['c1-cut1'] + cut2 = network['c1-cut2'] + + current_path = c8[1] + cut2[1] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (cut2[1] - clamp3[1], cut2[1] - clamp2[1], cut2[1] - cut1[2], cut2[1] + cut2[2])) + + def test_internal_step_to_cut_t2_between_cuts_steps_externally_and_traverses_segment_towards_t2_stopping_at_the_next_cut(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + c5 = network['c5'] + clamp2 = network['c1-clamp2'] + clamp3 = network['c1-clamp3'] + cut1 = network['c1-cut1'] + cut2 = network['c1-cut2'] + + current_path = cut1[1] + cut1[2] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (cut1[2] - clamp2[1], cut1[2] - clamp3[1], cut1[2] - cut2[1], cut1[2] + c5[1])) + + def test_internal_step_to_cut_t1_between_cuts_steps_externally_and_traverses_segment_towards_t1_stopping_at_the_next_cut(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + c8 = network['c8'] + clamp2 = network['c1-clamp2'] + clamp3 = network['c1-clamp3'] + cut1 = network['c1-cut1'] + cut2 = network['c1-cut2'] + + current_path = cut2[2] + cut2[1] + next_paths = self.path_provider.next_paths(current_path) + + _verify_paths(next_paths, (cut2[1] - clamp2[1], cut2[1] - clamp3[1], cut2[1] - cut1[2], cut2[1] + c8[1])) + + def test_starting_on_clamp_terminal_flagged_as_traversed_segment_only_steps_externally(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + c3 = network['c3'] + clamp1 = network['c1-clamp1'] + + next_paths = self.path_provider.next_paths(clamp1[1] - clamp1[1]) + _verify_paths(next_paths, (clamp1[1] + c3[1], )) + + def test_starting_on_clamp_terminal_that_flagged_as_not_traversed_segment_steps_externally_and_traverses(self): + network = CutsAndClampsNetwork.multi_cut_and_clamp_network().network + + c3 = network['c3'] + clamp1 = network['c1-clamp1'] + cut1 = network['c1-cut1'] + c1 = network['c1'] + + next_paths = self.path_provider.next_paths(clamp1[1] + clamp1[1]) + _verify_paths(next_paths, (clamp1[1] + c3[1], clamp1[1] - c1[1], clamp1[1] - cut1[1])) + + def test_traverse_with_cut_with_unknown_length_from_t1_does_not_return_clamp_with_known_length_from_t1(self, subtests): + # + # (Cut with null length is treated as 0.0 + # 1 b0 21*1 cut1 2*-c1-*-21 b2 2 + # 1 + # clamp1 + # + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls() # c1 + .with_clamp(length_from_terminal_1=1.0) # c1-clamp1 + .with_cut() # c1-cut1 + .to_breaker() # b2 + ).network + + c1 = network['c1'] + b0 = network['b0'] + b2 = network['b2'] + clamp = network['c1-clamp1'] + cut = network['c1-cut1'] + + with subtests.test('Traverse from T1 towards T2'): + current_path = b0[2] + c1[1] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (c1[1] - cut[1], )) + + with subtests.test('Traverse from T2 towards T1'): + current_path = b2[1] + c1[2] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (c1[2] - clamp[1], c1[2] - cut[2])) + + def test_multiple_cuts_at_same_positions_step_to_all_cuts_at_that_position(self, subtests): + # + # *1 cut2 2* + # 1 b0 21-c1-*1 cut1 2*-c1-*-21 b2 2 + # + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls() # c1 + .with_cut(length_from_terminal_1=1.0) # c1-cut1 + .with_cut(length_from_terminal_1=1.0) # c1-cut2 + .to_breaker() # b2 + ).network + + c1 = network['c1'] + b0 = network['b0'] + b2 = network['b2'] + cut1 = network['c1-cut1'] + cut2 = network['c1-cut2'] + + with subtests.test('Traverse from T1 towards T2 should have both cuts t1'): + current_path = b0[2] + c1[1] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (c1[1] - cut1[1], c1[1] - cut2[1])) + + with subtests.test('Traverse from T2 towards T1 should have both cuts t2'): + current_path = b2[1] + c1[2] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (c1[2] - cut1[2], c1[2] - cut2[2])) + + with subtests.test('Internal step on cut1 t1 to t2 has cut2.t2 and traverses towards segment T2'): + current_path = cut1[1] + cut1[2] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (cut1[2] - c1[2], cut1[2] - cut2[2])) + + with subtests.test('Internal step on cut1 t2 to t1 traverses towards segment T2'): + current_path = cut1[2] + cut1[1] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (cut1[1] - c1[1], cut1[1] - cut2[1])) + + def test_cut_and_clamp_without_length_only_returns_clamp_on_T1_side_of_cut(self, subtests): + # + # 1 b0 21*1 cut1 2*-c1-*-21 b2 2 + # 1 + # clamp1 + # + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls() # c1 + .with_clamp() # c1-clamp1 + .with_cut() # c1-cut1 + .to_breaker() # b2 + ).network + + c1 = network['c1'] + b0 = network['b0'] + b2 = network['b2'] + clamp = network['c1-clamp1'] + cut = network['c1-cut1'] + + with subtests.test('Traverse from T1 towards T2'): + current_path = b0[2] + c1[1] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (c1[1] - cut[1], c1[1] - clamp[1])) + + with subtests.test('Traverse from T2 towards T1'): + current_path = b2[1] + c1[2] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (c1[2] - cut[2], )) + + with subtests.test('Internally stepped on cut T1 to T2, traverse towards c1.t2'): + current_path = cut[1] + cut[2] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (cut[2] - c1[2], )) + + with subtests.test('Internally stepped on cut T2 to T2, traverse towards c1.t1'): + current_path =cut[2] + cut[1] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (cut[1] - c1[1], cut[1] - clamp[1])) + + def test_cut_and_clamp_at_same_length_only_returns_clamp_on_T1_side_of_cut(self, subtests): + # + # 1 b0 21*1 cut1 2*-c1-*-21 b2 2 + # 1 + # clamp1 + # + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls() # c1 + .with_clamp(length_from_terminal_1=1.0) # c1-clamp1 + .with_cut(length_from_terminal_1=1.0) # c1-cut1 + .to_breaker() # b2 + ).network + + c1 = network['c1'] + b0 = network['b0'] + b2 = network['b2'] + clamp = network['c1-clamp1'] + cut = network['c1-cut1'] + + with subtests.test('Traverse from T1 towards T2'): + current_path = b0[2] + c1[1] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (c1[1] - cut[1], c1[1] - clamp[1])) + + with subtests.test('Traverse from T2 towards T1'): + current_path = b2[1] + c1[2] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (c1[2] - cut[2], )) + + with subtests.test('Internally stepped on cut T1 to T2, traverse towards c1.t2'): + current_path = cut[1] + cut[2] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (cut[2] - c1[2], )) + + with subtests.test('Internally stepped on cut T2 to T2, traverse towards c1.t1'): + current_path =cut[2] + cut[1] + next_paths = self.path_provider.next_paths(current_path) + _verify_paths(next_paths, (cut[1] - c1[1], cut[1] - clamp[1])) + + def test_multiple_clamps_at_same_position_does_not_return_the_other_clamps_more_then_once(self): + # (Cut with None length is treated as 0.0 + # clamp2 + # 1 + # 1 b0 21*1 cut1 2*-c1-*-21 b2 2 + # 1 + # clamp1 + # + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls() # c1 + .with_clamp() # c1-clamp1 + .with_clamp() # c1-clamp2 + .with_cut() # c1-cut1 + .to_breaker() # b2 + ).network + + c1 = network['c1'] + clamp1 = network['c1-clamp1'] + clamp2 = network['c1-clamp2'] + cut = network['c1-cut1'] + + next_paths = self.path_provider.next_paths(clamp1[1] + clamp1[1]) + _verify_paths(next_paths, (clamp1[1] - c1[1], clamp1[1] - clamp2[1], clamp1[1] - cut[1])) + + def test_unrealistic_cuts_and_clamps_network_doesnt_break_the_pathing_algorith(self, subtests): + network = self._acls_with_clamps_and_cuts_at_same_position_network() + + b0 = network['b0'] + b2 = network['b2'] + c1 = network['c1'] + clamp1 = network['c1-clamp1'] + clamp2 = network['c1-clamp2'] + clamp3 = network['c1-clamp3'] + clamp4 = network['c1-clamp4'] + clamp5 = network['c1-clamp5'] + clamp6 = network['c1-clamp6'] + cut1 = network['c1-cut1'] + cut2 = network['c1-cut2'] + cut3 = network['c1-cut3'] + cut4 = network['c1-cut4'] + cut5 = network['c1-cut5'] + cut6 = network['c1-cut6'] + cClamp1 = network['c-clamp1'] + cCut1t1 = network['c-cut1t1'] + cCut1t2 = network['c-cut1t2'] + cClamp3 = network['c-clamp3'] + cCut3t1 = network['c-cut3t1'] + cCut3t2 = network['c-cut3t2'] + cClamp5 = network['c-clamp5'] + cCut5t1 = network['c-cut5t1'] + cCut5t2 = network['c-cut5t2'] + + with subtests.test("traverse from c1.t1 should get clamps at start and stop at both cuts at start"): + next_paths = self.path_provider.next_paths(b0[2] + c1[1]) + _verify_paths(next_paths, (c1[1] - clamp1[1], c1[1] - clamp2[1], c1[1] - cut1[1], c1[1] - cut2[1])) + + with subtests.test('traverse from clamp1.t1 should traverse to other clamp at start, stop at both cuts at start and c1.t1'): + next_paths = self.path_provider.next_paths(cClamp1[1] + clamp1[1]) + _verify_paths(next_paths, (clamp1[1] - clamp2[1], clamp1[1] - c1[1], clamp1[1] - cut1[1], clamp1[1] - cut2[1])) + + with subtests.test("traverse from cut1.t1 (external) should traverse to cut2.t1, clamps at start, c1.t1 and internally step to cut1.t2"): + next_paths = self.path_provider.next_paths(cCut1t1[1] + cut1[1]) + _verify_paths(next_paths, (cut1[1] - cut2[1], cut1[1] - clamp1[1], cut1[1] - clamp2[1], cut1[1] - c1[1], cut1[1] + cut1[2])) + + with subtests.test("traverse from cut1.t1 (internal) should traverse to cut2.t1, clamps at start, c1.t1 and step to cCut1"): + next_paths = self.path_provider.next_paths(cut1[2] + cut1[1]) + _verify_paths(next_paths, (cut1[1] - cut2[1], cut1[1] - clamp1[1], cut1[1] - clamp2[1], cut1[1] - c1[1], cut1[1] + cCut1t1[1])) + + with subtests.test("traverse from cut1.t2 (external) should traverse to cut2.t2, middle cuts, middle clamps, internally step to c1.t1"): + next_paths = self.path_provider.next_paths(cCut1t2[1] + cut1[2]) + _verify_paths(next_paths, (cut1[2] - cut2[2], cut1[2] - clamp3[1], cut1[2] - clamp4[1], cut1[2] - cut3[1], cut1[2] - cut4[1], cut1[2] + cut1[1])) + + with subtests.test("traverse from cut1.t2 (internal) should traverse to cut2.t2, middle cuts, middle clamps and externally to cCut1t2"): + next_paths = self.path_provider.next_paths(cut1[1] + cut1[2]) + _verify_paths(next_paths, (cut1[2] - cut2[2], cut1[2] - clamp3[1], cut1[2] - clamp4[1], cut1[2] - cut3[1], cut1[2] - cut4[1], cut1[2] + cCut1t2[1])) + + with subtests.test("traverse from middle clamp (clamp3) should traverse to cuts at start, middle cuts, and other middle clamp"): + next_paths = self.path_provider.next_paths(cClamp3[1] + clamp3[1]) + _verify_paths(next_paths, (clamp3[1] - cut1[2], clamp3[1] - cut2[2], clamp3[1] - cut3[1], clamp3[1] - cut4[1], clamp3[1] - clamp4[1])) + + with subtests.test("traverse from cut3.t1 (external) should traverse to cut4.t1, start cuts, middle clamps, and internally step to cut3.t2"): + next_paths = self.path_provider.next_paths(cCut3t1[1] + cut3[1]) + _verify_paths(next_paths, (cut3[1] - cut4[1], cut3[1] - cut1[2], cut3[1] - cut2[2], cut3[1] - clamp3[1], cut3[1] - clamp4[1], cut3[1] + cut3[2])) + + with subtests.test("traverse from cut3.t1 (internal) should traverse to cut2.t1, clamps at start, middle clamp and step to cCut3t1"): + next_paths = self.path_provider.next_paths(cut3[2] + cut3[1]) + _verify_paths(next_paths, (cut3[1] - cut4[1], cut3[1] - cut1[2], cut3[1] - cut2[2], cut3[1] - clamp3[1], cut3[1] - clamp4[1], cut3[1] + cCut3t1[1])) + + with subtests.test("traverse from cut3.t2 (external) should traverse to cut4.t2, end cuts, end clamps and internally step to cut3.t1"): + next_paths = self.path_provider.next_paths(cCut3t2[1] + cut3[2]) + _verify_paths(next_paths, (cut3[2] - cut4[2], cut3[2] - cut5[1], cut3[2] - cut6[1], cut3[2] - clamp5[1], cut3[2] - clamp6[1], cut3[2] + cut3[1])) + + with subtests.test("traverse from cut3.t2 (internal) should traverse to cut4.t2, end cuts, end clamps and externally to cut3t2"): + next_paths = self.path_provider.next_paths(cut3[1] + cut3[2]) + _verify_paths(next_paths, (cut3[2] - cut4[2], cut3[2] - cut5[1], cut3[2] - cut6[1], cut3[2] - clamp5[1], cut3[2] - clamp6[1], cut3[2] + cCut3t2[1])) + + with subtests.test("traverse from end clamp (clamp5) should traverse to middle cuts, end cuts and other end clamp"): + next_paths = self.path_provider.next_paths(cClamp5[1] + clamp5[1]) + _verify_paths(next_paths, (clamp5[1] - cut3[2], clamp5[1] - cut4[2], clamp5[1] - cut5[1], clamp5[1] - cut6[1], clamp5[1] - clamp6[1])) + + with subtests.test("traverse from cut5.t1 (external) should traverse to cut6.t1, middle cuts, end clamps, and internally step to cut5.t2"): + next_paths = self.path_provider.next_paths(cCut5t1[1] + cut5[1]) + _verify_paths(next_paths, (cut5[1] - cut6[1], cut5[1] - cut3[2], cut5[1] - cut4[2], cut5[1] - clamp5[1], cut5[1] - clamp6[1], cut5[1] + cut5[2])) + + with subtests.test("traverse from cut5.t1 (internal) should traverse to cut6.t1, middle cuts, end clamps, and step to cCut5t1"): + next_paths = self.path_provider.next_paths(cut5[2] + cut5[1]) + _verify_paths(next_paths, (cut5[1] - cut6[1], cut5[1] - cut3[2], cut5[1] - cut4[2], cut5[1] - clamp5[1], cut5[1] - clamp6[1], cut5[1] + cCut5t1[1])) + + with subtests.test("traverse from cut5.t1 (external) should traverse to cut6.t2, c1.t2, and internally step out to cut5.t1"): + next_paths = self.path_provider.next_paths(cCut5t2[2] + cut5[2]) + _verify_paths(next_paths, (cut5[2] - cut6[2], cut5[2] - c1[2], cut5[2] + cut5[1])) + + with subtests.test("traverse from cut5.t2 (internal) should traverse to cut6.t2, c1.t2, end step externally to cCut5t2"): + next_paths = self.path_provider.next_paths(cut5[1] + cut5[2]) + _verify_paths(next_paths, (cut5[2] - cut6[2], cut5[2] - c1[2], cut5[2] + cCut5t2[1])) + + with subtests.test("traverse from c1.t2 should get cuts at end"): + next_paths = self.path_provider.next_paths(b2[1] + c1[2]) + _verify_paths(next_paths, (c1[2] - cut5[2], c1[2] - cut6[2])) + + def test_traverses_from_single_clamp_on_a_segment(self): + n = (TestNetworkBuilder() + .from_acls() # c0 + .with_clamp() # c0-clamp1 + .branch_from('c0-clamp1', 1) + .to_source() # s1 + ).network + + source = n['s1'] + clamp1 = n['c0-clamp1'] + c0 = n['c0'] + + _verify_paths(self.path_provider.next_paths(source[1] + clamp1[1]), + ((clamp1[1] - c0[1]), (clamp1[1] - c0[2]))) + + def test_traverses_from_both_sides_of_a_single_cut(self, subtests): + n = (TestNetworkBuilder() + .from_acls() # c0 + .with_cut() # c0-cut1 + .branch_from('c0-cut1', 1) + .to_source() # s1 + .branch_from('c0-cut1', 2) + .to_source() # s2 + ).network + + source1 = n['s1'] + source2 = n['s2'] + cut1 = n['c0-cut1'] + c0 = n['c0'] + + with subtests.test("goes from t1 side of cut and finds t1 side of segment"): + _verify_paths(self.path_provider.next_paths(source1[1] + cut1[1]), + ((cut1[1] - c0[1]), (cut1[1] + cut1[2]))) + + with subtests.test("goes from t2 side of cut and finds t2 side of segment"): + _verify_paths(self.path_provider.next_paths(source2[1] + cut1[2]), + ((cut1[2] - c0[2]), cut1[2] + cut1[1])) + + def _busbar_network(self) -> NetworkService: + # 1 + # b0 + # bbs1 1-2-1 bbs2 + # -----| |----- + # 1 1 1 1 + # b3 b4 b5 b6 + # 2 2 2 2 + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_busbar_section() # bbs1 + .branch_from('b0', 2) + .to_busbar_section() # bbs2 + .branch_from('bbs1', 1) + .to_breaker() # b3 + .branch_from('bbs1', 1) + .to_breaker() # b4 + .branch_from('bbs2', 1) + .to_breaker() # b5 + .branch_from('bbs2', 1) + .to_breaker() # b6 + ).network + + bbs1 = network['bbs1'] + bbs2 = network['bbs2'] + b0: Breaker = network['b0'] + b3 = network['b3'] + b4 = network['b4'] + b5 = network['b5'] + b6 = network['b6'] + + b0_terms = list(b0[2].connectivity_node.terminals) + for term in (b0[2], bbs1[1], bbs2[1], b3[1], b4[1], b5[1], b6[1]): + assert term in b0_terms + + return network + + def _acls_with_clamps_network(self) -> NetworkService: + # + # clamp1 + # 1 + # 1 b0 21 ---*---c1---*---21 b2 2 + # 1 + # clamp2 + # + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls() # c1 + .with_clamp(length_from_terminal_1=1.0) # c1-clamp1 + .with_clamp(length_from_terminal_1=2.0) # c1-clamp2 + .to_breaker() # b2 + ).network + segment: AcLineSegment = network['c1'] + + return network + + def _acls_with_clamps_and_cuts_at_same_position_network(self) -> NetworkService: + # Drawing this is very messy, so it will be described in writing: + # The network has 2 Breakers (b0, b2) with an AcLineSegment (c1) between them ( 1 b0 21--c1--21 b2 1 ) + # There is then 2 Clamps and 2 Cuts at the following position on c1 + # * At the start (0.0) (clamp1, clamp2, cut1, cut2) + # * In the middle (length 1.0) (clamp3, clamp4, cut3, cut4) + # * At the end (length 2.0) (clamp5, clamp6, cut5, cut6) + # On each clamp terminal there is a separate AcLineSegment connected to it. (ids of c-clampX) + # On each cut terminal (both 1 and 2) there is a separate AcLineSegment connected to it. (ids of c-cutXtN) + + segment_length = 2.0 + + def acls_length(acls: AcLineSegment) -> None: + acls.length = segment_length + + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls(action=acls_length) # c1 + # At start (combination of 0 and unknown). + .with_clamp(length_from_terminal_1=0.0) # c1-clamp1 + .with_clamp(length_from_terminal_1=None) # c1-clamp2 + .with_cut(length_from_terminal_1=0.0) # c1-cut1 + .with_cut(length_from_terminal_1=None) # c1-cut2 + + # At mid-point. + .with_clamp(length_from_terminal_1=segment_length / 2) # c1-clamp3 + .with_clamp(length_from_terminal_1=segment_length / 2) # c1-clamp4 + .with_cut(length_from_terminal_1=segment_length / 2) # c1-cut3 + .with_cut(length_from_terminal_1=segment_length / 2) # c1-cut4 + + # At end. + .with_clamp(length_from_terminal_1=segment_length) # c1-clamp5 + .with_clamp(length_from_terminal_1=segment_length) # c1-clamp6 + .with_cut(length_from_terminal_1=segment_length) # c1-cut5 + .with_cut(length_from_terminal_1=segment_length) # c1-cut6 + .to_breaker() # b2 + .from_acls(mrid='c-clamp1') + .connect_to('c1-clamp1', 1, from_terminal=1) + .from_acls(mrid='c-clamp2') + .connect_to('c1-clamp2', 1, from_terminal=1) + .from_acls(mrid='c-cut1t1') + .connect_to('c1-cut1', 1, from_terminal=1) + .from_acls(mrid='c-cut1t2') + .connect_to('c1-cut1', 2, from_terminal=1) + .from_acls(mrid='c-cut2t1') + .connect_to('c1-cut2', 1, from_terminal=1) + .from_acls(mrid='c-cut2t2') + .connect_to('c1-cut2', 2, from_terminal=1) + .from_acls(mrid='c-clamp3') + .connect_to('c1-clamp3', 1, from_terminal=1) + .from_acls(mrid='c-clamp4') + .connect_to('c1-clamp4', 1, from_terminal=1) + .from_acls(mrid='c-cut3t1') + .connect_to('c1-cut3', 1, from_terminal=1) + .from_acls(mrid='c-cut3t2') + .connect_to('c1-cut3', 2, from_terminal=1) + .from_acls(mrid='c-cut4t1') + .connect_to('c1-cut4', 1, from_terminal=1) + .from_acls(mrid='c-cut4t2') + .connect_to('c1-cut4', 2, from_terminal=1) + .from_acls(mrid='c-clamp5') + .connect_to('c1-clamp5', 1, from_terminal=1) + .from_acls(mrid='c-clamp6') + .connect_to('c1-clamp6', 1, from_terminal=1) + .from_acls(mrid='c-cut5t1') + .connect_to('c1-cut5', 1, from_terminal=1) + .from_acls(mrid='c-cut5t2') + .connect_to('c1-cut5', 2, from_terminal=1) + .from_acls(mrid='c-cut6t1') + .connect_to('c1-cut6', 1, from_terminal=1) + .from_acls(mrid='c-cut6t2') + .connect_to('c1-cut6', 2, from_terminal=1) + ).network + + return network + + +def _verify_paths(in_paths: Generator[NetworkTraceStep.Path, None, None], in_expected: Iterable[NetworkTraceStep.Path], check_length=True): + paths = list(in_paths) + expected = list(in_expected) + for path in paths: + if path in expected: + continue + assert paths == expected # doesn't represent the actual comparison, but dumps both sides of it. diff --git a/test/services/network/tracing/networktrace/util.py b/test/services/network/tracing/networktrace/util.py new file mode 100644 index 000000000..d284b5583 --- /dev/null +++ b/test/services/network/tracing/networktrace/util.py @@ -0,0 +1,41 @@ +# Copyright 2025 Zeppelin Bend Pty Ltd +# 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/. +from unittest.mock import MagicMock, Mock + +from zepben.evolve import NetworkTraceStep, ConductingEquipment, StepContext + + +def mock_nts(path: NetworkTraceStep.Path=None, + num_terminal_steps=0, + num_equipment_steps=0, + data=None + ): + nts = Mock(wraps=NetworkTraceStep(path, num_terminal_steps, num_equipment_steps, data)) + nts.configure_mock( + num_terminal_steps=3, + num_equipment_steps=1, + path=path + ) + return nts + + +def mock_nts_path(to_equipment: ConductingEquipment=None, + traced_internally: bool=None): + if traced_internally: + terminal = Mock() + next_path = MagicMock(wraps=NetworkTraceStep.Path(terminal, terminal)) + else: + next_path = MagicMock(wraps=NetworkTraceStep.Path(Mock(), Mock())) + + + return next_path + +def mock_ctx(value: int=None): + ctx = MagicMock(spec=StepContext) + if value is not None: + ctx.get_value = lambda key: value + + return ctx + diff --git a/test/services/network/tracing/phases/test_phase_inferrer.py b/test/services/network/tracing/phases/test_phase_inferrer.py index 195b24ca5..65141073d 100644 --- a/test/services/network/tracing/phases/test_phase_inferrer.py +++ b/test/services/network/tracing/phases/test_phase_inferrer.py @@ -2,12 +2,14 @@ # 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/. +import logging from typing import List, Optional from unittest.mock import patch import pytest from services.network.tracing.phases.util import validate_phases_from_term_or_equip +from zepben.evolve.database.sqlite.network.network_database_reader import NetworkDatabaseReader from zepben.evolve import TestNetworkBuilder, PhaseCode, SinglePhaseKind, PhaseInferrer, Terminal, NetworkService, NetworkStateOperators A = SinglePhaseKind.A @@ -16,17 +18,11 @@ N = SinglePhaseKind.N NONE = SinglePhaseKind.NONE -async def run_phase_inferrer(network: NetworkService, do_current=True) -> tuple[List[PhaseInferrer.InferredPhase], List[PhaseInferrer.InferredPhase]]: - normal = await PhaseInferrer().run(network, network_state_operators=NetworkStateOperators.NORMAL) - current = await PhaseInferrer().run(network, network_state_operators=NetworkStateOperators.CURRENT) if do_current else [] - return normal, current - class TestPhaseInferrer: """ Test the `PhaseInferrer` """ - @pytest.mark.skip() ## FIXME: @pytest.mark.asyncio async def test_ab_to_bc_to_xy_to_abc(self, caplog): """ @@ -49,16 +45,15 @@ async def test_ab_to_bc_to_xy_to_abc(self, caplog): validate_phases_from_term_or_equip(network, "c2", [B, NONE]) validate_phases_from_term_or_equip(network, "c3", [NONE, B, NONE]) - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "c1", PhaseCode.BC) validate_phases_from_term_or_equip(network, "c2", PhaseCode.BC) validate_phases_from_term_or_equip(network, "c3", PhaseCode.ABC) self._validate_returned_phases(network, changes, ['c1', 'c3']) - self._validate_log(caplog, correct=["c1", "c3", 'c1', 'c3']) + self._validate_log(caplog, correct=["c1", "c3"]) - @pytest.mark.skip() # FIXME: @pytest.mark.asyncio async def test_abn_to_bcn_to_xyn_to_abcn(self, caplog): """ @@ -81,16 +76,15 @@ async def test_abn_to_bcn_to_xyn_to_abcn(self, caplog): validate_phases_from_term_or_equip(network, "c2", [B, NONE, N]) validate_phases_from_term_or_equip(network, "c3", [NONE, B, NONE, N]) - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "c1", PhaseCode.BCN) validate_phases_from_term_or_equip(network, "c2", PhaseCode.BCN) validate_phases_from_term_or_equip(network, "c3", PhaseCode.ABCN) self._validate_returned_phases(network, changes, ['c1', 'c3']) - self._validate_log(caplog, correct=["c1", "c3", 'c1', 'c3']) + self._validate_log(caplog, correct=["c1", "c3"]) - @pytest.mark.skip() # FIXME: @pytest.mark.asyncio async def test_bc_to_ac_to_xy_to_abc(self, caplog): """ @@ -113,14 +107,14 @@ async def test_bc_to_ac_to_xy_to_abc(self, caplog): validate_phases_from_term_or_equip(network, "c2", [NONE, C]) validate_phases_from_term_or_equip(network, "c3", [NONE, NONE, C]) - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "c1", PhaseCode.AC) validate_phases_from_term_or_equip(network, "c2", PhaseCode.AC) validate_phases_from_term_or_equip(network, "c3", PhaseCode.ABC) self._validate_returned_phases(network, changes, ['c1', 'c3']) - self._validate_log(caplog, correct=["c1", "c3", 'c1', 'c3']) + self._validate_log(caplog, correct=["c1", "c3"]) @pytest.mark.asyncio async def test_abc_to_xyn_to_xy_to_bc(self, caplog): @@ -144,13 +138,13 @@ async def test_abc_to_xyn_to_xy_to_bc(self, caplog): validate_phases_from_term_or_equip(network, "c2", PhaseCode.BC) validate_phases_from_term_or_equip(network, "c3", PhaseCode.BC) - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "c1", PhaseCode.BCN) validate_phases_from_term_or_equip(network, "c2", PhaseCode.BC) validate_phases_from_term_or_equip(network, "c3", PhaseCode.BC) - self._validate_log(caplog, correct=["c1", 'c1']) + self._validate_log(caplog, correct=["c1"]) self._validate_returned_phases(network, changes, ['c1']) @pytest.mark.asyncio @@ -175,14 +169,14 @@ async def test_abc_to_xy_to_xyn_to_bc(self, caplog): validate_phases_from_term_or_equip(network, "c2", [B, C, NONE]) validate_phases_from_term_or_equip(network, "c3", PhaseCode.BC) - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "c1", PhaseCode.BC) validate_phases_from_term_or_equip(network, "c2", PhaseCode.BCN) validate_phases_from_term_or_equip(network, "c3", PhaseCode.BC) self._validate_returned_phases(network, changes, ['c2']) - self._validate_log(caplog, correct=["c2", 'c2']) + self._validate_log(caplog, correct=["c2"]) @pytest.mark.asyncio async def test_abc_to_n_to_abcn(self, caplog): @@ -206,14 +200,14 @@ async def test_abc_to_n_to_abcn(self, caplog): validate_phases_from_term_or_equip(network, "c2", PhaseCode.NONE) validate_phases_from_term_or_equip(network, "c3", PhaseCode.NONE) - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "c1", PhaseCode.ABC) validate_phases_from_term_or_equip(network, "c2", PhaseCode.N) validate_phases_from_term_or_equip(network, "c3", PhaseCode.ABCN) self._validate_returned_phases(network, changes, ['c2', 'c3']) - self._validate_log(caplog, correct=["c2", "c3", 'c2', 'c3']) + self._validate_log(caplog, correct=["c2", "c3"]) @pytest.mark.asyncio async def test_abc_to_b_to_xyn(self, caplog): @@ -239,14 +233,14 @@ async def test_abc_to_b_to_xyn(self, caplog): validate_phases_from_term_or_equip(network, "c2", PhaseCode.B) validate_phases_from_term_or_equip(network, "c3", [B, NONE, NONE]) - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "c1", PhaseCode.ABC) validate_phases_from_term_or_equip(network, "c2", PhaseCode.B) validate_phases_from_term_or_equip(network, "c3", PhaseCode.BCN) self._validate_returned_phases(network, changes, ['c3']) - self._validate_log(caplog, suspect=["c3", 'c3']) + self._validate_log(caplog, suspect=["c3"]) @pytest.mark.asyncio async def test_abc_to_c_to_xyn(self, caplog): @@ -272,14 +266,14 @@ async def test_abc_to_c_to_xyn(self, caplog): validate_phases_from_term_or_equip(network, "c2", PhaseCode.C) validate_phases_from_term_or_equip(network, "c3", [C, NONE, NONE]) - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "c1", PhaseCode.ABC) validate_phases_from_term_or_equip(network, "c2", PhaseCode.C) validate_phases_from_term_or_equip(network, "c3", [C, NONE, N]) self._validate_returned_phases(network, changes, ['c3']) - self._validate_log(caplog, suspect=["c3", 'c3']) + self._validate_log(caplog, suspect=["c3"]) @pytest.mark.asyncio async def test_abc_to_a_to_xn(self, caplog): @@ -303,14 +297,14 @@ async def test_abc_to_a_to_xn(self, caplog): validate_phases_from_term_or_equip(network, "c2", PhaseCode.A) validate_phases_from_term_or_equip(network, "c3", [A, NONE]) - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "c1", PhaseCode.ABC) validate_phases_from_term_or_equip(network, "c2", PhaseCode.A) validate_phases_from_term_or_equip(network, "c3", PhaseCode.AN) self._validate_returned_phases(network, changes, ['c3']) - self._validate_log(caplog, correct=["c3", 'c3']) + self._validate_log(caplog, correct=["c3"]) @pytest.mark.asyncio async def test_dual_feed_an_to_abcn(self, caplog): @@ -333,16 +327,15 @@ async def test_dual_feed_an_to_abcn(self, caplog): validate_phases_from_term_or_equip(network, "c1", [A, NONE, NONE, N]) validate_phases_from_term_or_equip(network, "s2", PhaseCode.AN) - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "s0", PhaseCode.AN) validate_phases_from_term_or_equip(network, "c1", PhaseCode.ABCN) validate_phases_from_term_or_equip(network, "s2", PhaseCode.AN) self._validate_returned_phases(network, changes, ['c1']) - self._validate_log(caplog, correct=["c1", 'c1']) + self._validate_log(caplog, correct=["c1"]) - @pytest.mark.skip() # FIXME: @pytest.mark.asyncio async def test_abcn_to_n_to_ab_to_xy(self, caplog): """ @@ -367,7 +360,7 @@ async def test_abcn_to_n_to_ab_to_xy(self, caplog): validate_phases_from_term_or_equip(network, "c3", PhaseCode.NONE) validate_phases_from_term_or_equip(network, "c4", PhaseCode.NONE) - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "c1", PhaseCode.ABCN) validate_phases_from_term_or_equip(network, "c2", PhaseCode.N) @@ -375,7 +368,7 @@ async def test_abcn_to_n_to_ab_to_xy(self, caplog): validate_phases_from_term_or_equip(network, "c4", PhaseCode.AB) self._validate_returned_phases(network, changes, ['c3']) - self._validate_log(caplog, correct=["c3", 'c3']) + self._validate_log(caplog, correct=["c3"]) @pytest.mark.asyncio async def test_with_open_switch(self, caplog): @@ -399,7 +392,7 @@ async def test_with_open_switch(self, caplog): validate_phases_from_term_or_equip(network, "b2", PhaseCode.ABC, PhaseCode.NONE) validate_phases_from_term_or_equip(network, "c3", PhaseCode.NONE) - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "c1", PhaseCode.ABC) validate_phases_from_term_or_equip(network, "b2", PhaseCode.ABC, PhaseCode.NONE) @@ -408,7 +401,6 @@ async def test_with_open_switch(self, caplog): self._validate_returned_phases(network, changes, []) self._validate_log(caplog) - @pytest.mark.skip() # #FIXME: @pytest.mark.asyncio async def test_validate_directions_with_dropped_direction_loop(self, caplog): """ @@ -440,7 +432,7 @@ async def test_validate_directions_with_dropped_direction_loop(self, caplog): terminals = [network.get("c6-t2", Terminal)] + [t for t in network.objects(Terminal) if t.mrid != "c6-t2"] with patch.object(NetworkService, 'objects', wraps=lambda _: terminals): - changes = await run_phase_inferrer(network) + changes = await self.run_phase_inferrer(network) validate_phases_from_term_or_equip(network, "c2", PhaseCode.AC, PhaseCode.AC) validate_phases_from_term_or_equip(network, "c3", PhaseCode.ABC, PhaseCode.ABC) @@ -452,7 +444,22 @@ async def test_validate_directions_with_dropped_direction_loop(self, caplog): validate_phases_from_term_or_equip(network, "c9", PhaseCode.ABC, PhaseCode.ABC) self._validate_returned_phases(network, changes, ['c6']) - self._validate_log(caplog, correct=["c6", 'c6']) + self._validate_log(caplog, correct=["c6"]) + + + class LoggerOnly: + _logger = logging.getLogger(__name__) + + async def run_phase_inferrer(self, network: NetworkService, do_current=True) -> tuple[List[PhaseInferrer.InferredPhase], List[PhaseInferrer.InferredPhase]]: + normal = await PhaseInferrer().run(network, network_state_operators=NetworkStateOperators.NORMAL) + current = await PhaseInferrer().run(network, network_state_operators=NetworkStateOperators.CURRENT) if do_current else [] + + # This has to be called manually as we don't actually use the NetworkDatabaseReader + # and copy pasting the logging code in here didn't make any sense. + # noinspection PyTypeChecker + NetworkDatabaseReader._log_inferred_phases(self.LoggerOnly, normal, current) + + return normal, current @staticmethod def _validate_returned_phases(network: NetworkService, @@ -468,8 +475,11 @@ def check_phases(phases): if current_phases: check_phases(current_phases) - def _validate_log(self, caplog, correct: Optional[List[str]] = None, suspect: Optional[List[str]] = None): + """ + This test is removed from the kotlin SDK, kept it in here as it caught some bugs that otherwise would have + slipped through, remove whenever it seems logical. + """ correct = correct or [] suspect = suspect or [] diff --git a/test/services/network/tracing/test_assign_to_feeders.py b/test/services/network/tracing/test_assign_to_feeders.py index 03b2442ae..2730c4c34 100644 --- a/test/services/network/tracing/test_assign_to_feeders.py +++ b/test/services/network/tracing/test_assign_to_feeders.py @@ -5,7 +5,9 @@ from typing import Iterable import pytest -from zepben.evolve import Equipment, TestNetworkBuilder, Feeder, BaseVoltage, Tracing, NetworkStateOperators +from zepben.evolve import Equipment, TestNetworkBuilder, Feeder, BaseVoltage, Tracing, NetworkStateOperators, CurrentTransformer, FaultIndicator, \ + ProtectedSwitch, CurrentRelay, ProtectionRelayScheme, ProtectionRelaySystem, PhotoVoltaicUnit, PowerElectronicsConnection, Junction, ConductingEquipment, \ + PowerTransformerEnd def validate_equipment(equipment: Iterable[Equipment], *expected_mrids: str): @@ -20,6 +22,16 @@ class TestAssignToFeeders: bv_hv = BaseVoltage(nominal_voltage=11000) bv_lv = BaseVoltage(nominal_voltage=400) + @staticmethod + def base_voltage(ce: ConductingEquipment, voltage: BaseVoltage): + ce.base_voltage = voltage + + def _make_hv(self, ce: ConductingEquipment): + return self.base_voltage(ce, self.bv_hv) + + def _make_lv(self, ce: ConductingEquipment): + return self.base_voltage(ce, self.bv_lv) + @pytest.mark.asyncio @pytest.mark.parametrize('feeder_start_point_between_conductors_network', [(False,)], indirect=True) async def test_applies_to_equipment_on_head_terminal_side(self, feeder_start_point_between_conductors_network): @@ -56,9 +68,9 @@ async def test_assigns_equipment_to_feeders_with_loops(self, caplog, loop_under_ async def test_stops_at_lv_equipment(self): # noinspection PyArgumentList network_service = (TestNetworkBuilder() - .from_breaker(action=lambda ce: setattr(ce, "base_voltage", self.bv_hv)) - .to_acls(action=lambda ce: setattr(ce, "base_voltage", self.bv_hv)) - .to_acls(action=lambda ce: setattr(ce, "base_voltage", self.bv_lv)) + .from_breaker(action=self._make_hv) + .to_acls(action=self._make_hv) + .to_acls(action=self._make_lv) .add_feeder("b0") .network) @@ -74,10 +86,10 @@ async def test_stops_at_lv_equipment(self): async def test_includes_transformers(self): # noinspection PyArgumentList network_service = (TestNetworkBuilder() - .from_breaker(action=lambda ce: setattr(ce, "base_voltage", self.bv_hv)) - .to_acls(action=lambda ce: setattr(ce, "base_voltage", self.bv_hv)) - .to_power_transformer(end_actions=[lambda ce: setattr(ce, "base_voltage", self.bv_hv), lambda ce: setattr(ce, "base_voltage", self.bv_lv)]) - .to_acls(action=lambda ce: setattr(ce, "base_voltage", self.bv_lv)) + .from_breaker(action=self._make_hv) + .to_acls(action=self._make_hv) + .to_power_transformer(end_actions=[self._make_hv, self._make_lv]) + .to_acls(action=self._make_lv) .add_feeder("b0") .network) @@ -88,3 +100,159 @@ async def test_includes_transformers(self): await Tracing.assign_equipment_to_feeders().run(network_service, NetworkStateOperators.NORMAL) validate_equipment(feeder.equipment, "b0", "c1", "tx2") + + @pytest.mark.asyncio + async def test_assigns_auxilary_equipment_to_feeder(self): + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls() # c1 + .add_feeder('b0') + ).network + + a1 = CurrentTransformer(mrid='a1') + a1.terminal = network.get('c1-t1') + network.add(a1) + + a2 = FaultIndicator(mrid='a2') + a2.terminal = network.get('c1-t1') + network.add(a2) + + feeder = network['fdr2'] + + await Tracing.assign_equipment_to_feeders().run(network, NetworkStateOperators.NORMAL) + validate_equipment(feeder.equipment, 'b0', 'c1', 'a1', 'a2') + + @pytest.mark.asyncio + async def test_assigns_protection_equipment_to_feeder(self): + network = (TestNetworkBuilder() + .from_breaker() # b0 + .add_feeder('b0') + ).network + + ps = network.get('b0', ProtectedSwitch) + cr = CurrentRelay(mrid='cr1') + ps.add_relay_function(cr) + cr.add_protected_switch(ps) + + prs = ProtectionRelayScheme(mrid='psr2') + cr.add_scheme(prs) + prs.add_function(cr) + + prsys = ProtectionRelaySystem(mrid='prsys3') + prs.system = prsys + prsys.add_scheme(prs) + + network.add(cr) + network.add(prs) + network.add(prsys) + + feeder = network['fdr1'] + + await Tracing.assign_equipment_to_feeders().run(network, NetworkStateOperators.NORMAL) + + validate_equipment(feeder.equipment, 'b0', 'prsys3') + + @pytest.mark.asyncio + async def test_assigns_power_electronic_units_to_feeder(self): + peu1 = PhotoVoltaicUnit(mrid='peu1') + + def pec_action(this: PowerElectronicsConnection): + this.add_unit(peu1) + peu1.power_electronics_connection = this + + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_power_electronics_connection(action=pec_action) # pec1 + .add_feeder('b0') + ).network + + network.add(peu1) + + feeder = network['fdr2'] + + await Tracing.assign_equipment_to_feeders().run(network, NetworkStateOperators.NORMAL) + + validate_equipment(feeder.equipment, 'b0', 'pec1', 'peu1') + + @pytest.mark.asyncio + async def test_can_be_run_from_a_single_terminal(self): + # + # 1 b0 21--c1--2 j2 31--c3--21--c4--2 + # 2 + # 1 + # | + # c5 + # | + # 21--c6--2 + # + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls() # c1 + .to_junction(num_terminals=3) # j2 + .to_acls() # c3 + .to_acls() # c4 + .from_acls() # c5 + .to_acls() # c6 + .connect('j2', 'c5', 2, 1) + .add_feeder('b0') # fdr7 + ).network + + feeder = network['fdr7'] + junction = network['j2'] + + feeder.add_equipment(junction) + junction.add_container(feeder) + + await Tracing.assign_equipment_to_feeders().run(network, NetworkStateOperators.NORMAL) + + # b0 is included from the network builder. + # j2 was added to allow us to test the terminal based assignment. + # c3 and c4 should have been added via the trace. + # c1, c5 and c6 shouldn't have been added if the assignment only went out t3 of j2. + validate_equipment(feeder.equipment, 'b0', 'j2', 'c3', 'c4') + + @pytest.mark.asyncio + async def test_energizes_all_lv_feeders_for_a_dist_tx_site_that_is_energized(self): + # + # 1--c4--21 b5 2 + # 1 b0 21--c121 tx2 21--c3--2 + # 1--c6--21 b7 2 + # + network = (TestNetworkBuilder() + .from_breaker(action=self._make_hv) # b0 + .to_acls(action=self._make_hv) # c1 + .to_power_transformer(end_actions=[lambda t: setattr(t, 'rated_u', self.bv_hv.nominal_voltage), lambda t: setattr(t, 'rated_u', self.bv_lv.nominal_voltage)]) # tx2 + .to_acls(action=self._make_lv) # c3 + .to_acls(action=self._make_lv) # c4 + .to_breaker(action=self._make_lv) # b5 + .from_acls(action=self._make_lv) # c6 + .to_breaker(action=self._make_lv) # b7 + .connect('c3', 'c6', 2, 1) + .add_feeder('b0') # fdr8 + .add_lv_feeder('tx2') # lvf9 + .add_lv_feeder('b5') # lvf10 + .add_lv_feeder('b7') # lvf11 + .add_site(['tx2', 'c3', 'c4', 'b5', 'c6', 'b7']) # site12 + ).network + + feeder = network['fdr8'] + + await Tracing.assign_equipment_to_feeders().run(network, NetworkStateOperators.NORMAL) + + # We ensure the HV trace stopped at the transformer, but the additional LV feeders from b5 and b7 are still + # marked as energized through the dist substation site. + validate_equipment(feeder.equipment, 'b0', 'c1', 'tx2') + assert [it.mrid for it in feeder.normal_energized_lv_feeders] == ['lvf9', 'lvf10', 'lvf11'] + + @pytest.mark.asyncio + async def test_does_not_trace_out_from_terminal_belonging_to_open_switch(self): + network = (TestNetworkBuilder() + .from_breaker(is_normally_open=True) # b0 + .to_acls() # c1 + .add_feeder('b0') + ).network + + await Tracing.assign_equipment_to_feeders().run(network, NetworkStateOperators.NORMAL, network['b0'][2]) + + feeder = network['fdr2'] + validate_equipment(feeder.equipment, 'b0') diff --git a/test/services/network/tracing/test_assign_to_lv_feeders.py b/test/services/network/tracing/test_assign_to_lv_feeders.py index 96edf2f76..4740cabc0 100644 --- a/test/services/network/tracing/test_assign_to_lv_feeders.py +++ b/test/services/network/tracing/test_assign_to_lv_feeders.py @@ -5,18 +5,33 @@ from typing import Iterable import pytest -from zepben.evolve import Equipment, TestNetworkBuilder, Feeder, BaseVoltage, LvFeeder, NetworkStateOperators +from zepben.evolve import Equipment, TestNetworkBuilder, Feeder, BaseVoltage, LvFeeder, NetworkStateOperators, CurrentTransformer, FaultIndicator, \ + ProtectedSwitch, CurrentRelay, ProtectionRelayScheme, ProtectionRelaySystem, PhotoVoltaicUnit, PowerElectronicsConnection, ConductingEquipment, Breaker from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing def validate_equipment(equipment: Iterable[Equipment], *expected_mrids: str): - equip_mrids = [e.mrid for e in equipment] + equip_mrids = tuple(e.mrid for e in equipment) + assert equip_mrids == expected_mrids for mrid in expected_mrids: assert mrid in equip_mrids class TestAssignToLvFeeders: + bv_hv = BaseVoltage(nominal_voltage=11000) + bv_lv = BaseVoltage(nominal_voltage=400) + + @staticmethod + def base_voltage(ce: ConductingEquipment, voltage: BaseVoltage): + ce.base_voltage = voltage + + def _make_hv(self, ce: ConductingEquipment): + return self.base_voltage(ce, self.bv_hv) + + def _make_lv(self, ce: ConductingEquipment): + return self.base_voltage(ce, self.bv_lv) + @pytest.mark.asyncio @pytest.mark.parametrize('feeder_start_point_between_conductors_network', [(True,)], indirect=True) async def test_applies_to_equipment_on_head_terminal_side(self, feeder_start_point_between_conductors_network): @@ -39,7 +54,7 @@ async def test_assigns_equipment_to_feeders_with_loops(self, caplog, loop_under_ await Tracing.assign_equipment_to_lv_feeders().run(loop_under_feeder_head_network) lv_feeder = loop_under_feeder_head_network.get("f", LvFeeder) - validate_equipment(lv_feeder.equipment, "s0", "c1", "c2", "c3", "c4") + validate_equipment(lv_feeder.equipment, "s0", "c1", "c3", "c4", "c2") @pytest.mark.asyncio async def test_stops_at_hv_equipment(self): @@ -133,7 +148,7 @@ async def test_single_feeder_powers_multiple_lv_feeders(self): assert set(lv_feeder2.normal_energizing_feeders) == {feeder} @pytest.mark.asyncio - async def test_single_feeder_powers_multiple_lv_feeders(self): + async def test_multiple_feeders_power_single_lv_feeder(self): network_service = (TestNetworkBuilder() .from_breaker() .add_feeder("b0") @@ -151,3 +166,211 @@ async def test_single_feeder_powers_multiple_lv_feeders(self): assert set(feeder1.normal_energized_lv_feeders) == {lv_feeder} assert set(feeder2.normal_energized_lv_feeders) == {lv_feeder} assert set(lv_feeder.normal_energizing_feeders) == {feeder1, feeder2} + + @pytest.mark.asyncio + async def test_assigns_auxiliary_equipment_to_lv_feeder(self): + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_acls() # c1 + .add_lv_feeder('b0') + ).network + + a1 = CurrentTransformer(mrid='a1') + a1.terminal = network.get('c1-t1') + network.add(a1) + + a2 = FaultIndicator(mrid='a2') + a2.terminal = network.get('c1-t1') + network.add(a2) + + lv_feeder = network['lvf2'] + + await Tracing.assign_equipment_to_lv_feeders().run(network, NetworkStateOperators.NORMAL) + validate_equipment(lv_feeder.equipment, 'b0', 'c1', 'a1', 'a2') + + @pytest.mark.asyncio + async def test_assigns_protection_equipment_to_feeder(self): + network = (TestNetworkBuilder() + .from_breaker() # b0 + .add_lv_feeder('b0') + ).network + + ps = network.get('b0', ProtectedSwitch) + cr = CurrentRelay(mrid='cr1') + ps.add_relay_function(cr) + cr.add_protected_switch(ps) + + prs = ProtectionRelayScheme(mrid='psr2') + cr.add_scheme(prs) + prs.add_function(cr) + + prsys = ProtectionRelaySystem(mrid='prsys3') + prs.system = prsys + prsys.add_scheme(prs) + + network.add(cr) + network.add(prs) + network.add(prsys) + + lv_feeder = network['lvf1'] + + await Tracing.assign_equipment_to_lv_feeders().run(network, NetworkStateOperators.NORMAL) + + validate_equipment(lv_feeder.equipment, 'b0', 'prsys3') + + @pytest.mark.asyncio + async def test_assigns_power_electronic_units_to_feeder(self): + peu1 = PhotoVoltaicUnit(mrid='peu1') + + def pec_action(this: PowerElectronicsConnection): + this.add_unit(peu1) + peu1.power_electronics_connection = this + + network = (TestNetworkBuilder() + .from_breaker() # b0 + .to_power_electronics_connection(action=pec_action) # pec1 + .add_lv_feeder('b0') + ).network + + network.add(peu1) + + lv_feeder = network['lvf2'] + + await Tracing.assign_equipment_to_lv_feeders().run(network, NetworkStateOperators.NORMAL) + + validate_equipment(lv_feeder.equipment, 'b0', 'pec1', 'peu1') + + @pytest.mark.asyncio + async def lv_feeders_detect_back_feeds_for_energizing_feeders(self): + # 1 b0 21 tx1 21--c2--21--c3--21 tx4 21 b5 2 + # + # NOTE: Transformer is deliberately set to use the hv voltage as their base voltage to ensure they are still processed. + # + network = (TestNetworkBuilder() + .from_breaker(action=self._make_hv) # b0 + .to_power_transformer(action=self._make_hv) # tx1 + .to_acls(action=self._make_lv) # c2 + .to_acls(action=self._make_lv) # c3 + .to_power_transformer(action=self._make_hv) # tx4 + .to_breaker(action=self._make_hv) + .add_feeder("b0") + .add_lv_feeder("tx1") + .add_lv_feeder("tx4", 1) + .add_feeder("b5", 1) + ).network + + feeder6: Feeder = network["fdr6"] + feeder9: Feeder = network["fdr9"] + lv_feeder7: LvFeeder = network["lvf7"] + lv_feeder8: LvFeeder = network["lvf8"] + + await Tracing.assign_equipment_to_feeders().run(network, NetworkStateOperators.NORMAL) + await Tracing.assign_equipment_to_lv_feeders().run(network, NetworkStateOperators.NORMAL) + + assert feeder6.normal_energized_lv_feeders == [lv_feeder7, lv_feeder8] + assert feeder9.normal_energized_lv_feeders == [lv_feeder7, lv_feeder8] + assert lv_feeder7.normal_energizing_feeders == [feeder6, feeder9] + assert lv_feeder8.normal_energizing_feeders == [feeder6, feeder9] + + @pytest.mark.asyncio + async def test_lv_feeders_detect_back_feeds_for_dist_substation_sites(self): + # + # 1--c2--21 b3 2 + # 1 tx0 21--c1--2 + # 1--c4--21 b5 21--c6--21 b7 2 + # + network = (TestNetworkBuilder() + .from_power_transformer(end_actions=[lambda t: setattr(t, 'rated_u', self.bv_hv.nominal_voltage), lambda t: setattr(t, 'rated_u', self.bv_lv.nominal_voltage)]) # tx0 + .to_acls(action=self._make_lv) # c1 + .to_acls(action=self._make_lv) # c2 + .to_breaker(action=self._make_lv) # b3 + .from_acls(action=self._make_lv) # c4 + .to_breaker(action=self._make_lv) # b5 + .to_acls(action=self._make_lv) # c6 + .to_breaker(action=self._make_lv) # b7 + .connect('c1', 'c4', 2, 1) + .add_lv_feeder('tx0') # lvf8 + .add_lv_feeder('b3') # lvf9 + .add_lv_feeder('b5') # lvf10 + .add_lv_feeder('b7', 1) # lvf11 + .add_site(['tx0', 'c1', 'c2', 'b3', 'c4', 'b5']) # site12 + ).network + + operators = NetworkStateOperators.NORMAL + b7: Breaker = network['b7'] + + feeder = Feeder() + lv_feeder8 = network['lvf8'] + operators.associate_energizing_feeder(feeder, lv_feeder8) + lv_feeder9 = network['lvf9'] + operators.associate_energizing_feeder(feeder, lv_feeder9) + lv_feeder10 = network['lvf10'] + operators.associate_energizing_feeder(feeder, lv_feeder10) + + # We create an LV feeder to assign from b7 with its associated energizing feeder, which we will test is assigned to all LV feeders + # in the dist substation site, not just the one on b5. + back_feed = Feeder() + lv_feeder = LvFeeder() + operators.associate_energizing_feeder(back_feed, lv_feeder) + + await Tracing.assign_equipment_to_lv_feeders().run( + b7.get_terminal_by_sn(1), + network.lv_feeder_start_points, + {}, + [lv_feeder], + operators + ) + + # Make sure the LV feeder trace stopped at the first LV feeder head. + assert [it.mrid for it in lv_feeder.equipment] == ['b7', 'c6', 'b5'] + + # Make sure both feeders are now considered to be energizing all LV feeders. + assert list(feeder.normal_energized_lv_feeders) == [lv_feeder8, lv_feeder9, lv_feeder10, lv_feeder] + assert list(back_feed.normal_energized_lv_feeders) == [lv_feeder, lv_feeder8, lv_feeder9, lv_feeder10] + + # Make sure all LV feeders are now considered to be energized by both feeders. + assert list(lv_feeder.normal_energizing_feeders) == [back_feed, feeder] + assert list(lv_feeder8.normal_energizing_feeders) == [feeder, back_feed] + assert list(lv_feeder9.normal_energizing_feeders) == [feeder, back_feed] + assert list(lv_feeder10.normal_energizing_feeders) == [feeder, back_feed] + + @pytest.mark.asyncio + async def test_assigns_normal_and_current_energising_feeders_based_on_state(self): + network = (TestNetworkBuilder() + .from_breaker() # b0 + .add_lv_feeder('b0') # lvf1 + ).network + + normal_feeder = Feeder() + current_feeder = Feeder() + breaker = network['b0'] + lv_feeder = network['lvf1'] + + breaker.add_container(normal_feeder) + breaker.add_current_container(current_feeder) + + await Tracing.assign_equipment_to_lv_feeders().run(network, NetworkStateOperators.NORMAL) + await Tracing.assign_equipment_to_lv_feeders().run(network, NetworkStateOperators.CURRENT) + + assert list(normal_feeder.normal_energized_lv_feeders) == [lv_feeder] + assert list(lv_feeder.normal_energizing_feeders) == [normal_feeder] + + assert list(current_feeder.current_energized_lv_feeders) == [lv_feeder] + assert list(lv_feeder.current_energizing_feeders) == [current_feeder] + + @pytest.mark.asyncio + async def test_does_not_trace_out_from_terminal_belonging_to_open_switch(self): + # + # 1 b0 21--c1--2 + # + network = (TestNetworkBuilder() + .from_breaker(is_normally_open=True) # b0 + .to_acls() # c1 + .add_lv_feeder('b0') # lvf2 + ).network + + await Tracing.assign_equipment_to_lv_feeders().run(network, NetworkStateOperators.NORMAL, network['b0'][2]) + + feeder = network['lvf2'] + validate_equipment(feeder.equipment, 'b0') + \ No newline at end of file diff --git a/test/services/network/tracing/traversal/test_queue.py b/test/services/network/tracing/traversal/test_queue.py new file mode 100644 index 000000000..63b48723f --- /dev/null +++ b/test/services/network/tracing/traversal/test_queue.py @@ -0,0 +1,79 @@ +# Copyright 2025 Zeppelin Bend Pty Ltd +# 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/. +from zepben.evolve import TraversalQueue, WeightedPriorityQueue +from zepben.evolve.services.network.tracing.traversal.queue import LIFODeque, FIFODeque + + +class TestQueue: + def test_lifo_queue(self): + queue = TraversalQueue.depth_first() + + for i in range(10): + queue.append(i) + assert queue.queue == LIFODeque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + assert len(queue) == 10 + + assert queue.pop() == 9 + + def test_fifo_queue(self): + queue = TraversalQueue.breadth_first() + + for i in range(10): + queue.append(i) + assert queue.queue == FIFODeque([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) + assert len(queue) == 10 + + assert queue.pop() == 0 + + def test_lifo_weighted_priority_queue(self): + weight = 0 + + queue = WeightedPriorityQueue( + lambda: TraversalQueue.depth_first(), + lambda t: weight + ) + for i in range(4): + queue.append(i) + + assert queue.pop() == 3 + assert queue.pop() == 2 + + weight = 1 + + for i in range(4): + queue.append(i) + + assert queue.pop() == 3 + assert queue.pop() == 2 + assert queue.pop() == 1 + assert queue.pop() == 0 + assert queue.pop() == 1 + assert queue.pop() == 0 + + def test_fifo_weighted_priority_queue(self): + weight = 0 + + queue = WeightedPriorityQueue( + lambda: TraversalQueue.breadth_first(), + lambda t: weight + ) + for i in range(4): + queue.append(i) + + assert queue.pop() == 0 + assert queue.pop() == 1 + + weight = 1 + + for i in range(4): + queue.append(i) + + assert queue.pop() == 0 + assert queue.pop() == 1 + assert queue.pop() == 2 + assert queue.pop() == 3 + assert queue.pop() == 2 + assert queue.pop() == 3 + diff --git a/test/services/network/tracing/traversal/test_traversal.py b/test/services/network/tracing/traversal/test_traversal.py index e30372c84..d2adcf1fa 100644 --- a/test/services/network/tracing/traversal/test_traversal.py +++ b/test/services/network/tracing/traversal/test_traversal.py @@ -3,18 +3,18 @@ # 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/. from collections import deque -from typing import Callable, TypeVar, Tuple, Any +from typing import Callable, TypeVar, Tuple, Any, Optional import pytest -from zepben.evolve import StepContext, Traversal, TraversalQueue, NetworkTrace, ContextValueComputer -from zepben.evolve.services.network.tracing.traversal.traversal import D +from zepben.evolve import StepContext, Traversal, TraversalQueue, ContextValueComputer T = TypeVar('T') +D = TypeVar('D') -class TraversalTest(Traversal[T, 'TestTraversal[T]']): - def __init__(self, queue_type, parent, +class TraversalTest(Traversal[T, D]): + def __init__(self, queue_type, parent: Optional["TraversalTest[T]"], can_visit_item: Callable[[T, StepContext], bool], can_action_item: Callable[[T, StepContext], bool], on_reset: Callable[[], Any]): @@ -32,13 +32,15 @@ def can_action_item(self, item: T, context: StepContext) -> bool: def on_reset(self): return self._on_reset_impl() - def create_new_this(self) -> D: + def create_new_this(self) -> "TraversalTest[int]": return TraversalTest(self._queue_type, self, self._can_visit_item_impl, self._can_action_item_impl, self._on_reset_impl) def _create_traversal(can_visit_item: Callable[[int, StepContext], bool]=lambda x, y: True, can_action_item: Callable[[int, StepContext], bool]=lambda x, y: True, - on_reset: Callable[[], Any]=lambda: None) -> TraversalTest[int]: + on_reset: Callable[[], Any]=lambda: None, + queue: TraversalQueue[int]=TraversalQueue.depth_first() + ) -> TraversalTest[int, D]: def queue_next(item, _, queue_item): if item < 0: @@ -46,23 +48,24 @@ def queue_next(item, _, queue_item): else: queue_item(item + 1) - queue_type = Traversal.BasicQueueType[int, TraversalTest[int]]( + queue_type = Traversal.BasicQueueType[int, TraversalTest[int, D]]( queue_next=Traversal.QueueNext(queue_next), - queue=TraversalQueue.depth_first() + queue=queue ) return TraversalTest(queue_type, None, can_visit_item, can_action_item, on_reset) -def _create_branching_traversal() -> TraversalTest[int]: +def _create_branching_traversal() -> TraversalTest[int, D]: def queue_next(item, _, queue_item, queue_branch): - if item == 100: - queue_branch(-100) - elif item % 10 == 0: - queue_branch(item + 1) - else: + if item == 0: + queue_branch(-10) + queue_branch(10) + elif item < 0: queue_item(item + 1) + else: + queue_item(item - 1) - queue_type = Traversal.BranchingQueueType[int, TraversalTest[int]]( + queue_type = Traversal.BranchingQueueType[int, TraversalTest[int, D]]( queue_next=Traversal.BranchingQueueNext(queue_next), queue_factory=lambda: TraversalQueue.depth_first(), branch_queue_factory=lambda: TraversalQueue.depth_first() @@ -276,7 +279,6 @@ async def test_only_actions_items_that_can_be_actioned(self): steps = [] await (_create_traversal(can_action_item=lambda item, _: item % 2 == 1) - .add_stop_condition(lambda item, _: item == 2) .add_stop_condition(lambda item, _: item == 3) .add_step_action(lambda item, _: steps.append(item)) .run(1)) @@ -307,31 +309,70 @@ async def test_supports_branching_traversals(self): def step_action(item, ctx): steps[item] = ctx - await(_create_branching_traversal() - .add_queue_condition(lambda item, ctx, x, y: ctx.branch_depth <= 2) - .add_step_action(step_action) - .run(1)) + trace =(_create_branching_traversal() + .add_queue_condition(lambda item, ctx, x, y: (ctx.branch_depth <= 1) and (item != 0)) + .add_step_action(step_action) + ) + await trace.run(0, can_stop_on_start_item=False) + + + assert not steps[0].is_branch_start_item + assert steps[0].is_start_item + assert steps[0].branch_depth == 0 + + assert steps[10].is_branch_start_item + assert steps[10].branch_depth == 1 assert not steps[1].is_branch_start_item - assert steps[1].is_start_item - assert steps[1].branch_depth == 0 + assert not steps[1].is_start_item + assert steps[1].branch_depth == 1 - assert not steps[10].is_branch_start_item - assert steps[10].branch_depth == 0 + assert steps[-10].is_branch_start_item + assert steps[-10].branch_depth == 1 - assert steps[11].is_branch_start_item - assert not steps[11].is_start_item - assert steps[11].branch_depth == 1 + assert not steps[-1].is_branch_start_item + assert not steps[-1].is_start_item + assert steps[-1].branch_depth == 1 - assert not steps[20].is_branch_start_item - assert steps[20].branch_depth == 1 + @pytest.mark.asyncio + async def test_can_stop_on_start_item_is_not_assessed_on_branch_start_items(self): + stop_condition_triggered = [] + + def stop_condition(item: int, context): + if abs(item) == 10: + stop_condition_triggered.append(True) + return stop_condition_triggered + + await (_create_branching_traversal() + .add_stop_condition(stop_condition) + .add_queue_condition(lambda x, ctx, y, z: ctx.branch_depth < 2) + .add_start_item(1) + .add_start_item(-1) + ).run(can_stop_on_start_item=False) + + assert all(stop_condition_triggered) - assert steps[21].is_branch_start_item - assert not steps[21].is_start_item - assert steps[21].branch_depth == 2 + @pytest.mark.asyncio + async def test_start_items_are_queued_before_traversal_starts_so_queue_type_is_honoured_for_start_items(self): + steps = [] + await (_create_traversal(queue=TraversalQueue.breadth_first()) + .add_stop_condition(lambda item, x: item >= 2 or item <= -2) + .add_step_action(lambda item, x: steps.append(item)) + .add_start_item(-1) + .add_start_item(1) + ).run() - assert not steps[30].is_branch_start_item - assert steps[30].branch_depth == 2 + assert steps == [-1, 1, -2, 2] - with pytest.raises(KeyError): - assert not steps[31] \ No newline at end of file + @pytest.mark.asyncio + async def test_multiple_start_items_respect_can_stop_on_start(self): + steps = [] + traversal = (_create_traversal(queue=TraversalQueue.breadth_first()) + .add_stop_condition(lambda item, x: True) + .add_step_action(lambda item, x: steps.append(item)) + .add_start_item(1) + .add_start_item(11) + ) + await traversal.run(can_stop_on_start_item=False) + + assert steps == [1, 11, 2, 12]