diff --git a/changelog.md b/changelog.md index b91960d8e..17e1cd9a8 100644 --- a/changelog.md +++ b/changelog.md @@ -9,9 +9,23 @@ and reapply directions where appropriate using `SetDirection`. * `Cut` supports adding a maximum of 2 terminals. * `NetworkTraceTracker` now uses a `set` to track visited objects, if you were using unhashable objects this will need to be addressed. +* Added a new `debug_logging` and `name` parameters to the constructor of the following traces. The helper functions in `Tracing` also have these parameters, + which defaults to `None` and `network_trace`, meaning anyone using these wrappers will be unaffected by the change: + * `AssignToFeeders` + * `AssignToLvFeeders` + * `ClearDirection` + * `FindSwerEquipment` + * `PhaseInferrer` + * `RemovePhases` + * `SetDirection` + * `SetPhases` +* `NetworkStateOperators` has a new abstract `description`. If you are creating custom operators you will need to add it. +* `StepAction` will now raise an exception if `apply` is overridden. override `_apply` instead, or pass the function to `__init__` ### New Features * Added `ClearDirection` that clears feeder directions. +* You can now pass a logger to all `Tracing` methods and `TestNetworkBuilder.build` to enable debug logging for the traces it runs. The debug logging will + include the results of all queue and stop condition checks, and each item that is stepped on. ### Enhancements * Tracing models with `Cut` and `Clamp` are now supported via the new tracing API. @@ -24,6 +38,9 @@ * 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. +* `NetworkTrace` now supports starting from a known `NetworkTraceStep.Path`. This allows you to force a trace to start in a particular direction, or to continue + a follow-up trace from a detected stop point. +* `Traversal.is_stopping`/`Traversal.is_not_stopping` now accept `StepAction` and any child classes, including those subclassing `StepActionWithContextValue` ### Fixes * When finding `LvFeeders` in the `Site` we will now exclude `LvFeeders` that start with an open `Switch` @@ -38,6 +55,8 @@ * `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`. +* Phases are now correctly assigned to the LV side of an LV2 transformer that is in parallel with a previously energised LV1 transformer. + ### Notes * None. diff --git a/src/zepben/evolve/__init__.py b/src/zepben/evolve/__init__.py index 8e5507d11..24e8749de 100644 --- a/src/zepben/evolve/__init__.py +++ b/src/zepben/evolve/__init__.py @@ -156,6 +156,7 @@ from zepben.evolve.services.network.translator.network_cim2proto import * from zepben.evolve.services.network.network_service import * +from zepben.evolve.services.network.tracing.networktrace.network_trace_step import * from zepben.evolve.services.network.tracing.connectivity.connectivity_result import * from zepben.evolve.services.network.tracing.connectivity.nominal_phase_path import * from zepben.evolve.services.network.tracing.connectivity.phase_paths import * @@ -204,6 +205,8 @@ 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.traversal.debug_logging import DebugLoggingWrapper + from zepben.evolve.services.network.tracing.find_swer_equipment import * from zepben.evolve.services.common.meta.data_source import * @@ -450,8 +453,6 @@ from zepben.evolve.database.sqlite.network.network_database_reader import * from zepben.evolve.database.sqlite.network.network_service_reader import * -from zepben.evolve.services.network.tracing.phases.set_phases import * - from zepben.evolve.testing.test_network_builder import * # @formatter:on diff --git a/src/zepben/evolve/model/busbranch/bus_branch.py b/src/zepben/evolve/model/busbranch/bus_branch.py index 8ace1d6ce..a822220d3 100644 --- a/src/zepben/evolve/model/busbranch/bus_branch.py +++ b/src/zepben/evolve/model/busbranch/bus_branch.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 __future__ import annotations + import abc from collections import Counter from dataclasses import dataclass, field from functools import reduce -from typing import Set, Tuple, FrozenSet, Dict, Callable, Union, TypeVar, Any, List, Generic, Optional, Iterable +from typing import Set, Tuple, FrozenSet, Dict, Callable, Union, TypeVar, Any, List, Generic, Optional, Iterable, TYPE_CHECKING -from zepben.evolve import Junction, BusbarSection, EquivalentBranch, Traversal, StepContext +from zepben.evolve import Junction, BusbarSection, EquivalentBranch, Traversal 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.aclinesegment import AcLineSegment @@ -20,6 +22,9 @@ from zepben.evolve.services.network.network_service import NetworkService from zepben.evolve.services.network.tracing.busbranch_trace import BusBranchTrace, BusBranchTraceStep +if TYPE_CHECKING: + from zepben.evolve import StepContext + __all__ = [ "BusBranchNetworkCreationValidator", "BusBranchNetworkCreator", @@ -28,7 +33,6 @@ "TerminalGrouping" ] - BBN = TypeVar('BBN') # Bus-Branch Network TN = TypeVar('TN') # Topological Node TB = TypeVar('TB') # Topological Branch @@ -911,9 +915,11 @@ async def _group_negligible_impedance_terminals( await trace.run() return tg + def _create_traversal_step_object(next_item: Union[Terminal, AcLineSegment]) -> BusBranchTraceStep: return BusBranchTraceStep(next_item) + def _process_terminal( tg: TerminalGrouping[ConductingEquipment], has_negligible_impedance: Callable[[ConductingEquipment], bool] @@ -1015,9 +1021,9 @@ def _next_common_acls( def can_process_ac_line(o: Terminal) -> bool: return o not in acls_terminals \ - and isinstance(o.conducting_equipment, AcLineSegment) \ - and has_common_impedance(o.conducting_equipment) \ - and o.conducting_equipment not in common_acls.conducting_equipment_group + and isinstance(o.conducting_equipment, AcLineSegment) \ + and has_common_impedance(o.conducting_equipment) \ + and o.conducting_equipment not in common_acls.conducting_equipment_group def is_non_forking_ac_line(t: Terminal) -> bool: return t.connectivity_node is not None and len(list(t.connectivity_node.terminals)) == 2 diff --git a/src/zepben/evolve/services/network/tracing/connectivity/transformer_phase_paths.py b/src/zepben/evolve/services/network/tracing/connectivity/transformer_phase_paths.py index c274aa723..2e6c90015 100644 --- a/src/zepben/evolve/services/network/tracing/connectivity/transformer_phase_paths.py +++ b/src/zepben/evolve/services/network/tracing/connectivity/transformer_phase_paths.py @@ -6,7 +6,7 @@ from zepben.evolve import SinglePhaseKind as Phase, NominalPhasePath, PhaseCode -__all__ = ["transformer_phase_paths"] +__all__ = ["transformer_phase_paths", "add_neutral"] def _path(from_phase: Phase, to_phase: Phase) -> NominalPhasePath: @@ -15,7 +15,7 @@ def _path(from_phase: Phase, to_phase: Phase) -> NominalPhasePath: # This is used to indicate that a transformer adds a neutral, and it should be energised from the transformer. -_add_neutral = _path(Phase.NONE, Phase.N) +add_neutral = _path(Phase.NONE, Phase.N) transformer_phase_paths: Dict[PhaseCode, Dict[PhaseCode, List[NominalPhasePath]]] = { PhaseCode.ABCN: { @@ -61,7 +61,7 @@ def _path(from_phase: Phase, to_phase: Phase) -> NominalPhasePath: PhaseCode.X: [_path(Phase.X, Phase.X)], }, PhaseCode.ABC: { - PhaseCode.ABCN: [_path(Phase.A, Phase.A), _path(Phase.B, Phase.B), _path(Phase.C, Phase.C), _add_neutral], + PhaseCode.ABCN: [_path(Phase.A, Phase.A), _path(Phase.B, Phase.B), _path(Phase.C, Phase.C), add_neutral], PhaseCode.ABC: [_path(Phase.A, Phase.A), _path(Phase.B, Phase.B), _path(Phase.C, Phase.C)], }, PhaseCode.ABN: { @@ -103,44 +103,44 @@ def _path(from_phase: Phase, to_phase: Phase) -> NominalPhasePath: PhaseCode.X: [_path(Phase.X, Phase.X)], }, PhaseCode.AB: { - PhaseCode.ABN: [_path(Phase.A, Phase.A), _path(Phase.B, Phase.B), _add_neutral], - PhaseCode.XYN: [_path(Phase.A, Phase.X), _path(Phase.B, Phase.Y), _add_neutral], - PhaseCode.AN: [_path(Phase.A, Phase.A), _add_neutral], - PhaseCode.XN: [_path(Phase.A, Phase.X), _add_neutral], + PhaseCode.ABN: [_path(Phase.A, Phase.A), _path(Phase.B, Phase.B), add_neutral], + PhaseCode.XYN: [_path(Phase.A, Phase.X), _path(Phase.B, Phase.Y), add_neutral], + PhaseCode.AN: [_path(Phase.A, Phase.A), add_neutral], + PhaseCode.XN: [_path(Phase.A, Phase.X), add_neutral], PhaseCode.AB: [_path(Phase.A, Phase.A), _path(Phase.B, Phase.B)], PhaseCode.XY: [_path(Phase.A, Phase.X), _path(Phase.B, Phase.Y)], PhaseCode.A: [_path(Phase.A, Phase.A)], PhaseCode.X: [_path(Phase.A, Phase.X)], }, PhaseCode.BC: { - PhaseCode.BCN: [_path(Phase.B, Phase.B), _path(Phase.C, Phase.C), _add_neutral], - PhaseCode.XYN: [_path(Phase.B, Phase.X), _path(Phase.C, Phase.Y), _add_neutral], - PhaseCode.BN: [_path(Phase.B, Phase.B), _add_neutral], - PhaseCode.XN: [_path(Phase.B, Phase.X), _add_neutral], + PhaseCode.BCN: [_path(Phase.B, Phase.B), _path(Phase.C, Phase.C), add_neutral], + PhaseCode.XYN: [_path(Phase.B, Phase.X), _path(Phase.C, Phase.Y), add_neutral], + PhaseCode.BN: [_path(Phase.B, Phase.B), add_neutral], + PhaseCode.XN: [_path(Phase.B, Phase.X), add_neutral], PhaseCode.BC: [_path(Phase.B, Phase.B), _path(Phase.C, Phase.C)], PhaseCode.XY: [_path(Phase.B, Phase.X), _path(Phase.C, Phase.Y)], PhaseCode.B: [_path(Phase.B, Phase.B)], PhaseCode.X: [_path(Phase.B, Phase.X)], }, PhaseCode.AC: { - PhaseCode.ACN: [_path(Phase.A, Phase.A), _path(Phase.C, Phase.C), _add_neutral], - PhaseCode.XYN: [_path(Phase.A, Phase.X), _path(Phase.C, Phase.Y), _add_neutral], - PhaseCode.CN: [_path(Phase.C, Phase.C), _add_neutral], - PhaseCode.XN: [_path(Phase.C, Phase.X), _add_neutral], + PhaseCode.ACN: [_path(Phase.A, Phase.A), _path(Phase.C, Phase.C), add_neutral], + PhaseCode.XYN: [_path(Phase.A, Phase.X), _path(Phase.C, Phase.Y), add_neutral], + PhaseCode.CN: [_path(Phase.C, Phase.C), add_neutral], + PhaseCode.XN: [_path(Phase.C, Phase.X), add_neutral], PhaseCode.AC: [_path(Phase.A, Phase.A), _path(Phase.C, Phase.C)], PhaseCode.XY: [_path(Phase.A, Phase.X), _path(Phase.C, Phase.Y)], PhaseCode.C: [_path(Phase.C, Phase.C)], PhaseCode.X: [_path(Phase.C, Phase.X)], }, PhaseCode.XY: { - PhaseCode.ABN: [_path(Phase.X, Phase.A), _path(Phase.Y, Phase.B), _add_neutral], - PhaseCode.BCN: [_path(Phase.X, Phase.B), _path(Phase.Y, Phase.C), _add_neutral], - PhaseCode.ACN: [_path(Phase.X, Phase.A), _path(Phase.Y, Phase.C), _add_neutral], - PhaseCode.XYN: [_path(Phase.X, Phase.X), _path(Phase.Y, Phase.Y), _add_neutral], - PhaseCode.AN: [_path(Phase.X, Phase.A), _add_neutral], - PhaseCode.BN: [_path(Phase.X, Phase.B), _add_neutral], - PhaseCode.CN: [_path(Phase.X, Phase.C), _add_neutral], - PhaseCode.XN: [_path(Phase.X, Phase.X), _add_neutral], + PhaseCode.ABN: [_path(Phase.X, Phase.A), _path(Phase.Y, Phase.B), add_neutral], + PhaseCode.BCN: [_path(Phase.X, Phase.B), _path(Phase.Y, Phase.C), add_neutral], + PhaseCode.ACN: [_path(Phase.X, Phase.A), _path(Phase.Y, Phase.C), add_neutral], + PhaseCode.XYN: [_path(Phase.X, Phase.X), _path(Phase.Y, Phase.Y), add_neutral], + PhaseCode.AN: [_path(Phase.X, Phase.A), add_neutral], + PhaseCode.BN: [_path(Phase.X, Phase.B), add_neutral], + PhaseCode.CN: [_path(Phase.X, Phase.C), add_neutral], + PhaseCode.XN: [_path(Phase.X, Phase.X), add_neutral], PhaseCode.AB: [_path(Phase.X, Phase.A), _path(Phase.Y, Phase.B)], PhaseCode.BC: [_path(Phase.X, Phase.B), _path(Phase.Y, Phase.C)], PhaseCode.AC: [_path(Phase.X, Phase.A), _path(Phase.Y, Phase.C)], @@ -151,40 +151,40 @@ def _path(from_phase: Phase, to_phase: Phase) -> NominalPhasePath: PhaseCode.X: [_path(Phase.X, Phase.X)], }, PhaseCode.A: { - PhaseCode.AN: [_path(Phase.A, Phase.A), _add_neutral], - PhaseCode.XN: [_path(Phase.A, Phase.X), _add_neutral], + PhaseCode.AN: [_path(Phase.A, Phase.A), add_neutral], + PhaseCode.XN: [_path(Phase.A, Phase.X), add_neutral], PhaseCode.AB: [_path(Phase.A, Phase.A), _path(Phase.NONE, Phase.B)], PhaseCode.XY: [_path(Phase.A, Phase.X), _path(Phase.NONE, Phase.Y)], PhaseCode.A: [_path(Phase.A, Phase.A)], PhaseCode.X: [_path(Phase.A, Phase.X)], - PhaseCode.ABN: [_path(Phase.A, Phase.A), _path(Phase.NONE, Phase.B), _add_neutral], - PhaseCode.XYN: [_path(Phase.A, Phase.X), _path(Phase.NONE, Phase.Y), _add_neutral], + PhaseCode.ABN: [_path(Phase.A, Phase.A), _path(Phase.NONE, Phase.B), add_neutral], + PhaseCode.XYN: [_path(Phase.A, Phase.X), _path(Phase.NONE, Phase.Y), add_neutral], }, PhaseCode.B: { - PhaseCode.BN: [_path(Phase.B, Phase.B), _add_neutral], - PhaseCode.XN: [_path(Phase.B, Phase.X), _add_neutral], + PhaseCode.BN: [_path(Phase.B, Phase.B), add_neutral], + PhaseCode.XN: [_path(Phase.B, Phase.X), add_neutral], PhaseCode.BC: [_path(Phase.B, Phase.B), _path(Phase.NONE, Phase.C)], PhaseCode.XY: [_path(Phase.B, Phase.X), _path(Phase.NONE, Phase.Y)], PhaseCode.B: [_path(Phase.B, Phase.B)], PhaseCode.X: [_path(Phase.B, Phase.X)], - PhaseCode.BCN: [_path(Phase.B, Phase.B), _path(Phase.NONE, Phase.C), _add_neutral], - PhaseCode.XYN: [_path(Phase.B, Phase.X), _path(Phase.NONE, Phase.Y), _add_neutral], + PhaseCode.BCN: [_path(Phase.B, Phase.B), _path(Phase.NONE, Phase.C), add_neutral], + PhaseCode.XYN: [_path(Phase.B, Phase.X), _path(Phase.NONE, Phase.Y), add_neutral], }, PhaseCode.C: { - PhaseCode.CN: [_path(Phase.C, Phase.C), _add_neutral], - PhaseCode.XN: [_path(Phase.C, Phase.X), _add_neutral], + PhaseCode.CN: [_path(Phase.C, Phase.C), add_neutral], + PhaseCode.XN: [_path(Phase.C, Phase.X), add_neutral], PhaseCode.AC: [_path(Phase.C, Phase.C), _path(Phase.NONE, Phase.A)], PhaseCode.XY: [_path(Phase.C, Phase.X), _path(Phase.NONE, Phase.Y)], PhaseCode.C: [_path(Phase.C, Phase.C)], PhaseCode.X: [_path(Phase.C, Phase.X)], - PhaseCode.ACN: [_path(Phase.C, Phase.C), _path(Phase.NONE, Phase.A), _add_neutral], - PhaseCode.XYN: [_path(Phase.C, Phase.X), _path(Phase.NONE, Phase.Y), _add_neutral], + PhaseCode.ACN: [_path(Phase.C, Phase.C), _path(Phase.NONE, Phase.A), add_neutral], + PhaseCode.XYN: [_path(Phase.C, Phase.X), _path(Phase.NONE, Phase.Y), add_neutral], }, PhaseCode.X: { - PhaseCode.AN: [_path(Phase.X, Phase.A), _add_neutral], - PhaseCode.BN: [_path(Phase.X, Phase.B), _add_neutral], - PhaseCode.CN: [_path(Phase.X, Phase.C), _add_neutral], - PhaseCode.XN: [_path(Phase.X, Phase.X), _add_neutral], + PhaseCode.AN: [_path(Phase.X, Phase.A), add_neutral], + PhaseCode.BN: [_path(Phase.X, Phase.B), add_neutral], + PhaseCode.CN: [_path(Phase.X, Phase.C), add_neutral], + PhaseCode.XN: [_path(Phase.X, Phase.X), add_neutral], PhaseCode.AB: [_path(Phase.X, Phase.A), _path(Phase.NONE, Phase.B)], PhaseCode.BC: [_path(Phase.X, Phase.B), _path(Phase.NONE, Phase.C)], PhaseCode.AC: [_path(Phase.X, Phase.C), _path(Phase.NONE, Phase.A)], @@ -193,9 +193,9 @@ def _path(from_phase: Phase, to_phase: Phase) -> NominalPhasePath: PhaseCode.B: [_path(Phase.X, Phase.B)], PhaseCode.C: [_path(Phase.X, Phase.C)], PhaseCode.X: [_path(Phase.X, Phase.X)], - PhaseCode.ABN: [_path(Phase.X, Phase.A), _path(Phase.NONE, Phase.B), _add_neutral], - PhaseCode.BCN: [_path(Phase.X, Phase.B), _path(Phase.NONE, Phase.C), _add_neutral], - PhaseCode.ACN: [_path(Phase.X, Phase.C), _path(Phase.NONE, Phase.A), _add_neutral], - PhaseCode.XYN: [_path(Phase.X, Phase.X), _path(Phase.NONE, Phase.Y), _add_neutral], + PhaseCode.ABN: [_path(Phase.X, Phase.A), _path(Phase.NONE, Phase.B), add_neutral], + PhaseCode.BCN: [_path(Phase.X, Phase.B), _path(Phase.NONE, Phase.C), add_neutral], + PhaseCode.ACN: [_path(Phase.X, Phase.C), _path(Phase.NONE, Phase.A), add_neutral], + PhaseCode.XYN: [_path(Phase.X, Phase.X), _path(Phase.NONE, Phase.Y), add_neutral], }, } 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 c8fae1a85..020bb0029 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 @@ -2,27 +2,29 @@ # 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 collections.abc import Collection -from typing import Iterable, Union, List, Dict, Any, Set, Type, Generator +from logging import Logger +from typing import Iterable, Union, List, Dict, Any, Set, Type, Generator, TYPE_CHECKING -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 +from zepben.evolve import Switch, ProtectedSwitch, PowerElectronicsConnection +from zepben.evolve.model.cim.iec61970.base.core.equipment_container import Feeder from zepben.evolve.model.cim.iec61970.base.wires.power_transformer import PowerTransformer from zepben.evolve.services.network.network_service import NetworkService - -__all__ = ["AssignToFeeders"] - +from zepben.evolve.services.network.tracing.networktrace.conditions.conditions import stop_at_open 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.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.networktrace.tracing import Tracing from zepben.evolve.services.network.tracing.traversal.step_context import StepContext +if TYPE_CHECKING: + from zepben.evolve import AuxiliaryEquipment, Equipment, LvFeeder, ConductingEquipment, EquipmentContainer, Terminal + +__all__ = ["AssignToFeeders", "BaseFeedersInternal"] + class AssignToFeeders: """ @@ -31,10 +33,15 @@ class AssignToFeeders: This class is backed by a `NetworkTrace`. """ - @staticmethod - async def run(network: NetworkService, - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, - start_terminal: Terminal=None): + def __init__(self, debug_logger: Logger = None): + self._debug_logger = debug_logger + + async def run( + self, + network: NetworkService, + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL, + start_terminal: Terminal = None + ): """ Assign equipment to feeders in the specified network, given an optional start terminal. @@ -44,12 +51,17 @@ async def run(network: NetworkService, * When a start terminal is provided, the trace will assign all feeders associated with the terminals equipment to all connected equipment. * If no start terminal is provided, all feeder head terminals in the network will be used instead, assigning their associated feeder. """ - await AssignToFeedersInternal(network_state_operators).run(network, start_terminal) + + await AssignToFeedersInternal( + network_state_operators, + self._debug_logger + ).run(network, start_terminal) class BaseFeedersInternal: - def __init__(self, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + def __init__(self, network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL, debug_logger: Logger = None): self.network_state_operators = network_state_operators + self._debug_logger = debug_logger def _feeders_from_terminal(self, terminal: Terminal) -> Generator[Feeder, None, None]: return terminal.conducting_equipment.feeders(self.network_state_operators) @@ -61,11 +73,9 @@ def _associate_equipment_with_containers(self, equipment_containers: Iterable[Eq self.network_state_operators.associate_equipment_and_container(it, feeder) def _associate_relay_systems_with_containers(self, equipment_containers: Iterable[EquipmentContainer], to_equipment: ProtectedSwitch): - self._associate_equipment_with_containers(equipment_containers, [ - scheme.system - for relayFunction in to_equipment.relay_functions - for scheme in relayFunction.schemes - if scheme.system is not None] + self._associate_equipment_with_containers( + equipment_containers, + [scheme.system 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): @@ -77,10 +87,9 @@ 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 = list(to_equipment.sites) lv_feeders = [] - if len(sites) > 0: + if len(sites := list(to_equipment.sites)) > 0: 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: @@ -92,10 +101,11 @@ def _feeder_try_energize_lv_feeders(self, feeders: Iterable[Feeder], lv_feeder_s class AssignToFeedersInternal(BaseFeedersInternal): - async def run(self, - network: NetworkService, - start_terminal: Terminal=None): - + async def run( + self, + network: NetworkService, + start_terminal: Terminal = None + ): feeder_start_points = network.feeder_start_points lv_feeder_start_points = network.lv_feeder_start_points terminal_to_aux_equipment = network.aux_equipment_by_terminal @@ -125,19 +135,18 @@ async def run_with_feeders(self, if terminal is None or len(feeders_to_assign) == 0: return - start_ce = terminal.conducting_equipment - - if isinstance(start_ce, Switch) and self.network_state_operators.is_open(start_ce): + if isinstance(start_ce := terminal.conducting_equipment, Switch) and self.network_state_operators.is_open(start_ce): self._associate_equipment_with_containers(feeders_to_assign, [start_ce]) else: traversal = await self._create_trace(terminal_to_aux_equipment, feeder_start_points, lv_feeder_start_points, feeders_to_assign) await traversal.run(terminal, False, can_stop_on_start_item=False) async def _create_trace(self, - terminal_to_aux_equipment: Dict[Terminal, List[AuxiliaryEquipment]], - feeder_start_points: Set[ConductingEquipment], - lv_feeder_start_points: Set[ConductingEquipment], - feeders_to_assign: List[Feeder]) -> NetworkTrace[Any]: + terminal_to_aux_equipment: Dict[Terminal, List[AuxiliaryEquipment]], + feeder_start_points: Set[ConductingEquipment], + lv_feeder_start_points: Set[ConductingEquipment], + feeders_to_assign: List[Feeder] + ) -> NetworkTrace[Any]: def _reached_lv(ce: ConductingEquipment): return True if ce.base_voltage and ce.base_voltage.nominal_voltage < 1000 else False @@ -148,22 +157,32 @@ def _reached_substation_transformer(ce: ConductingEquipment): async def step_action(nts: NetworkTraceStep, context: StepContext): await self._process(nts.path, context, terminal_to_aux_equipment, lv_feeder_start_points, feeders_to_assign) - return ( - Tracing.network_trace(self.network_state_operators, NetworkTraceActionType.ALL_STEPS) - .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)) - .add_step_action(step_action) + Tracing.network_trace( + network_state_operators=self.network_state_operators, + action_step_type=NetworkTraceActionType.ALL_STEPS, + debug_logger=self._debug_logger, + name=f'AssignToFeeders({self.network_state_operators.description})' + ) + .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, x, y: not _reached_substation_transformer(step.path.to_equipment) + ) + .add_queue_condition( + lambda step, ctx, x, y: not _reached_lv(step.path.to_equipment) + ) + .add_step_action(step_action) ) async def _process(self, - step_path: NetworkTraceStep.Path, - step_context: StepContext, - terminal_to_aux_equipment: Dict[Terminal, Collection[AuxiliaryEquipment]], - lv_feeder_start_points: Set[ConductingEquipment], - feeders_to_assign: List[Feeder]): + step_path: NetworkTraceStep.Path, + step_context: StepContext, + terminal_to_aux_equipment: Dict[Terminal, Collection[AuxiliaryEquipment]], + lv_feeder_start_points: Set[ConductingEquipment], + feeders_to_assign: List[Feeder]): if step_path.traced_internally and not step_context.is_start_item: return @@ -171,14 +190,10 @@ async def _process(self, 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) - - - - - + to_equip = step_path.to_equipment + if isinstance(to_equip, PowerTransformer): + self._feeder_try_energize_lv_feeders(feeders_to_assign, lv_feeder_start_points, to_equip) + elif isinstance(to_equip, ProtectedSwitch): + self._associate_relay_systems_with_containers(feeders_to_assign, to_equip) + elif isinstance(to_equip, PowerElectronicsConnection): + self._associate_power_electronic_units(feeders_to_assign, to_equip) 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 21622bbad..e00a73b34 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,63 +2,87 @@ # 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 functools import singledispatchmethod -from typing import Collection, List, Generator, TypeVar, Dict, Set, Type +from typing import Collection, List, Generator, TypeVar, Dict, Set, Type, TYPE_CHECKING -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 +from zepben.evolve import Switch, ProtectedSwitch, PowerElectronicsConnection, Terminal, ConductingEquipment, AuxiliaryEquipment, LvFeeder from zepben.evolve.services.network.network_service import NetworkService from zepben.evolve.services.network.tracing.feeder.assign_to_feeders import BaseFeedersInternal +from zepben.evolve.services.network.tracing.networktrace.conditions.conditions import stop_at_open 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.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 +if TYPE_CHECKING: + from logging import Logger + T = TypeVar("T") __all__ = ["AssignToLvFeeders"] class AssignToLvFeeders: - @singledispatchmethod - @staticmethod - async def run(network: NetworkService, - 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): """ Convenience class that provides methods for assigning LV feeders on a `NetworkService`. Requires that a Feeder have a normalHeadTerminal with associated ConductingEquipment. This class is backed by a `BasicTraversal`. """ - async def run(self, - network: NetworkService, - start_terminal: Terminal=None): + def __init__(self, debug_logger: Logger = None): + self._debug_logger = debug_logger + + @singledispatchmethod + async def run( + self, + network: NetworkService, + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL, + start_terminal: Terminal = None + ): """ Assign equipment to each feeder in the specified network. - :param network: The network containing the feeders to process - :param start_terminal: get the lv feeders for this `Terminal`s `ConductingEquipment` + :param network: The network containing the feeders to process. + :param network_state_operators: `NetworkStateOperators` to use for stateful operations. + :param start_terminal: get the lv feeders for this `Terminal`s `ConductingEquipment`. """ + await AssignToLvFeedersInternal( + network_state_operators, + self._debug_logger + ).run(network, start_terminal) + + @run.register + async def _( + self, + 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, + self._debug_logger + ).run_with_feeders( + terminal, + lv_feeder_start_points, + terminal_to_aux_equipment, + lv_feeders_to_assign + ) + + +class AssignToLvFeedersInternal(BaseFeedersInternal): + + async def run( + self, + network: NetworkService, + start_terminal: Terminal = None + ): lv_feeder_start_points = network.lv_feeder_start_points terminal_to_aux_equipment = network.aux_equipment_by_terminal @@ -86,34 +110,31 @@ async def run(self, terminal_to_aux_equipment, self._lv_feeders_from_terminal(start_terminal)) - async def run_with_feeders(self, - terminal: Terminal, - lv_feeder_start_points: Set[ConductingEquipment], - terminal_to_aux_equipment: Dict[Terminal, List[AuxiliaryEquipment]], - lv_feeders_to_assign: List[LvFeeder]): - + async def run_with_feeders( + self, + terminal: Terminal, + lv_feeder_start_points: Set[ConductingEquipment], + terminal_to_aux_equipment: Dict[Terminal, List[AuxiliaryEquipment]], + lv_feeders_to_assign: List[LvFeeder] + ): if terminal is None or len(lv_feeders_to_assign) == 0: return - start_ce = terminal.conducting_equipment - - if isinstance(start_ce, Switch) and self.network_state_operators.is_open(start_ce): + if isinstance(start_ce := terminal.conducting_equipment, Switch) and self.network_state_operators.is_open(start_ce): self._associate_equipment_with_containers(lv_feeders_to_assign, [start_ce]) else: traversal = self._create_trace(terminal_to_aux_equipment, lv_feeder_start_points, lv_feeders_to_assign) await traversal.run(terminal, False) - def _create_trace(self, - terminal_to_aux_equipment: Dict[Terminal, List[AuxiliaryEquipment]], - lv_feeder_start_points: Set[ConductingEquipment], - lv_feeders_to_assign: List[LvFeeder]) -> NetworkTrace[T]: - + def _create_trace( + self, + terminal_to_aux_equipment: Dict[Terminal, List[AuxiliaryEquipment]], + lv_feeder_start_points: Set[ConductingEquipment], + lv_feeders_to_assign: List[LvFeeder] + ) -> NetworkTrace[T]: def _reached_hv(ce: ConductingEquipment): return True if ce.base_voltage and ce.base_voltage.nominal_voltage >= 1000 else False - def queue_condition(next_step: NetworkTraceStep, nctx: StepContext, step: NetworkTraceStep, ctx: StepContext): - return next_step.data or not _reached_hv(next_step.path.to_equipment) - async def step_action(nts: NetworkTraceStep, context): await self._process(nts.path, nts.data, context, terminal_to_aux_equipment, lv_feeder_start_points, lv_feeders_to_assign) @@ -121,30 +142,34 @@ async def step_action(nts: NetworkTraceStep, context): Tracing.network_trace( network_state_operators=self.network_state_operators, action_step_type=NetworkTraceActionType.ALL_STEPS, - compute_data=(lambda _, __, next_path: next_path.to_equipment in lv_feeder_start_points) + debug_logger=self._debug_logger, + name=f'AssignToLvFeeders({self.network_state_operators.description})', + compute_data=(lambda x, y, next_path: next_path.to_equipment in lv_feeder_start_points) ) .add_condition(stop_at_open()) .add_stop_condition(lambda step, ctx: step.data) - .add_queue_condition(queue_condition) + .add_queue_condition( + lambda next_step, *args: next_step.data or not _reached_hv(next_step.path.to_equipment) + ) .add_step_action(step_action) ) - async def _process(self, - step_path: NetworkTraceStep.Path, - found_lv_feeder: bool, - step_context: StepContext, - terminal_to_aux_equipment: Dict[Terminal, Collection[AuxiliaryEquipment]], - lv_feeder_start_points: Set[ConductingEquipment], - lv_feeders_to_assign: List[LvFeeder]): - + async def _process( + self, + step_path: NetworkTraceStep.Path, + found_lv_feeder: bool, + step_context: StepContext, + terminal_to_aux_equipment: Dict[Terminal, Collection[AuxiliaryEquipment]], + lv_feeder_start_points: Set[ConductingEquipment], + lv_feeders_to_assign: List[LvFeeder] + ): 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 it in found_lv_feeders: + for it in (found_lv_feeders := list(self._find_lv_feeders(step_path.to_equipment, lv_feeder_start_points))): # 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) @@ -157,14 +182,14 @@ async def _process(self, 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) + to_equip = step_path.to_equipment + if isinstance(to_equip, ProtectedSwitch): + self._associate_relay_systems_with_containers(lv_feeders_to_assign, to_equip) + elif isinstance(to_equip, PowerElectronicsConnection): + self._associate_power_electronic_units(lv_feeders_to_assign, to_equip) def _find_lv_feeders(self, ce: ConductingEquipment, lv_feeder_start_points: Set[ConductingEquipment]) -> Generator[LvFeeder, None, None]: - sites = list(ce.sites) - if sites: + if sites := list(ce.sites): for site in sites: for feeder in site.find_lv_feeders(lv_feeder_start_points, self.network_state_operators): yield feeder 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 4bcde67b8..eb57894e7 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py +++ b/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py @@ -4,16 +4,17 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from __future__ import annotations +from logging import Logger from typing import TYPE_CHECKING, Any, Type from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal - 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.conditions.conditions import stop_at_open 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.operators.network_state_operators import NetworkStateOperators +from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing +from zepben.evolve.services.network.tracing.traversal.weighted_priority_queue import WeightedPriorityQueue if TYPE_CHECKING: from zepben.evolve import StepContext, NetworkTraceStep @@ -22,16 +23,21 @@ class ClearDirection: + """Convenience class that provides methods for clearing feeder direction on a `NetworkService`""" + def __init__(self, debug_logger: Logger = None): + self._debug_logger = debug_logger + + # NOTE: We used to try and remove directions in a single pass rather than clearing (and the reapplying where needed) to be more efficient. + # However, this caused all sorts of pain when trying to determine which directions to remove from dual fed equipment that contains inner loops. + # We decided it is so much simpler to just clear the directions and reapply from other feeder heads even if its a bit more computationally expensive. # - #NOTE: We used to try and remove directions in a single pass rather than clearing (and the reapplying where needed) to be more efficient. - # However, this caused all sorts of pain when trying to determine which directions to remove from dual fed equipment that contains inner loops. - # We decided it is so much simpler to just clear the directions and reapply from other feeder heads even if its a bit more computationally expensive. - # - async def run(self, - terminal: Terminal, - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL - ) -> list[Terminal]: + + async def run( + self, + terminal: Terminal, + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL + ) -> list[Terminal]: """ Clears the feeder direction from a terminal and the connected equipment chain. This clears directions even if equipment is dual fed. A set of feeder head terminals encountered while running will be returned and directions @@ -40,21 +46,18 @@ async def run(self, :param terminal: The `Terminal` from which to start the direction removal. :param network_state_operators: The `NetworkStateOperators` to be used when removing directions. - :return : A set of feeder head `terminals` encountered when clearing directions + :return : A set of feeder head `Terminal`s encountered when clearing directions """ - feeder_head_terminals: list[Terminal] = [] - trace = self._create_trace(network_state_operators, feeder_head_terminals) + trace = self._create_trace(network_state_operators, feeder_head_terminals := []) await trace.run(terminal, can_stop_on_start_item=False) return feeder_head_terminals - @staticmethod - 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 _create_trace( + self, + state_operators: Type[NetworkStateOperators], + visited_feeder_head_terminals: list[Terminal] + ) -> NetworkTrace[Any]: 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 @@ -63,10 +66,14 @@ def step_action(item: NetworkTraceStep, context: StepContext): Tracing.network_trace( network_state_operators=state_operators, action_step_type=NetworkTraceActionType.ALL_STEPS, + debug_logger=self._debug_logger, + name=f'ClearDirection({state_operators.description})', queue=WeightedPriorityQueue.process_queue( lambda it: it.path.to_terminal.phases.num_phases), ) - .add_condition(state_operators.stop_at_open()) - .add_queue_condition(queue_condition) + .add_condition(stop_at_open()) + .add_queue_condition( + lambda step, *args: state_operators.get_direction(step.path.to_terminal) != FeederDirection.NONE + ) .add_step_action(step_action) ) 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 6746c7d41..7539eef6f 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/set_direction.py +++ b/src/zepben/evolve/services/network/tracing/feeder/set_direction.py @@ -1,27 +1,27 @@ -# 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 __future__ import annotations from functools import singledispatchmethod +from logging import Logger 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.core.terminal import Terminal 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.model.cim.iec61970.base.wires.cut import Cut +from zepben.evolve.model.cim.iec61970.base.wires.power_transformer import PowerTransformer +from zepben.evolve.services.network.tracing.feeder.feeder_direction import FeederDirection from zepben.evolve.services.network.tracing.networktrace.conditions.conditions import stop_at_open +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.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: from zepben.evolve import NetworkService, Switch, ConductingEquipment @@ -34,11 +34,16 @@ class SetDirection: This class is backed by a [BranchRecursiveTraversal]. """ + def __init__(self, debug_logger: Logger = None): + self._debug_logger = debug_logger + @staticmethod - def _compute_data(reprocessed_loop_terminals: list[Terminal], - state_operators: Type[NetworkStateOperators], - step: NetworkTraceStep[FeederDirection], - next_path: NetworkTraceStep.Path) -> FeederDirection: + def _compute_data( + reprocessed_loop_terminals: list[Terminal], + state_operators: Type[NetworkStateOperators], + step: NetworkTraceStep[FeederDirection], + next_path: NetworkTraceStep.Path + ) -> FeederDirection: if next_path.to_equipment is BusbarSection: return FeederDirection.CONNECTOR @@ -57,7 +62,6 @@ def next_direction_func(): next_direction = next_direction_func() - # # NOTE: Stopping / short-circuiting by checking that the next direction is already present in the toTerminal, # causes certain looping network configurations not to be reprocessed. This means that some parts of # loops do not end up with BOTH directions. This is done to stop massive computational blowout on @@ -77,40 +81,35 @@ def next_direction_func(): return next_direction return FeederDirection.NONE - async def _create_traversal(self, state_operators: Type[NetworkStateOperators]) -> NetworkTrace[FeederDirection]: + def _create_traversal(self, state_operators: Type[NetworkStateOperators]) -> NetworkTrace[FeederDirection]: reprocessed_loop_terminals: list[Terminal] = [] - def queue_condition(nts: NetworkTraceStep, *args): - assert isinstance(nts.data, FeederDirection) - return nts.data != FeederDirection.NONE - - async def step_action(nts: NetworkTraceStep, *args): - state_operators.add_direction(nts.path.to_terminal, nts.data) - - def stop_condition(nts: NetworkTraceStep, *args): - return nts.path.to_terminal.is_feeder_head_terminal() or self._reached_substation_transformer(nts.path.to_terminal) - return ( Tracing.network_trace_branching( network_state_operators=state_operators, action_step_type=NetworkTraceActionType.ALL_STEPS, + debug_logger=self._debug_logger, + name=f'SetDirection({state_operators.description})', 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(stop_at_open()) - .add_stop_condition(stop_condition) - .add_queue_condition(queue_condition) - .add_step_action(step_action) + .add_stop_condition( + lambda nts, ctx: nts.path.to_terminal.is_feeder_head_terminal() or + self._reached_substation_transformer(nts.path.to_terminal) + ) + .add_queue_condition(lambda nts, *args: nts.data != FeederDirection.NONE) + .add_step_action( + lambda nts, ctx: state_operators.add_direction(nts.path.to_terminal, nts.data) + ) ) @staticmethod def _reached_substation_transformer(terminal: Terminal) -> bool: - ce = terminal.conducting_equipment - if not ce: - return False - - return isinstance(ce, PowerTransformer) and ce.num_substations() > 0 + if ce := terminal.conducting_equipment: + return isinstance(ce, PowerTransformer) and ce.num_substations() > 0 + return False @staticmethod def _is_normally_open_switch(conducting_equipment: Optional[ConductingEquipment]): @@ -122,23 +121,22 @@ async def run(self, network: NetworkService, network_state_operators: Type[Netwo Apply feeder directions from all feeder head terminals in the network. :param network: The network in which to apply feeder directions. - :param network_state_operators: The `NetworkStateOperators` to be used when setting feeder direction + :param network_state_operators: The `NetworkStateOperators` to be used when setting feeder direction """ - for terminal in (f.normal_head_terminal for f in network.objects(Feeder) if f.normal_head_terminal): - head_terminal = terminal.conducting_equipment - if head_terminal is not None: + for terminal in (f.normal_head_terminal for f in network.objects(Feeder) if f.normal_head_terminal): + if (head_terminal := terminal.conducting_equipment) is not None: if not network_state_operators.is_open(head_terminal, None): await self.run_terminal(terminal, network_state_operators) @run.register - async def run_terminal(self, terminal: Terminal, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + async def run_terminal(self, terminal: Terminal, network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL): """ Apply [FeederDirection.DOWNSTREAM] from the [terminal]. :param terminal: The terminal to start applying feeder direction from. - :param network_state_operators: The `NetworkStateOperators` to be used when setting feeder direction + :param network_state_operators: The `NetworkStateOperators` to be used when setting feeder direction """ - trav = await self._create_traversal(network_state_operators) - return await trav.run(terminal, FeederDirection.DOWNSTREAM, can_stop_on_start_item=False) + return await (self._create_traversal(network_state_operators) + .run(terminal, FeederDirection.DOWNSTREAM, can_stop_on_start_item=False)) diff --git a/src/zepben/evolve/services/network/tracing/find_swer_equipment.py b/src/zepben/evolve/services/network/tracing/find_swer_equipment.py index 425b2b61c..fcac9cb23 100644 --- a/src/zepben/evolve/services/network/tracing/find_swer_equipment.py +++ b/src/zepben/evolve/services/network/tracing/find_swer_equipment.py @@ -1,29 +1,30 @@ -# 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 Set, Union, Generator, AsyncGenerator +from __future__ import annotations + +from typing import Set, Union, AsyncGenerator, Type, TYPE_CHECKING from typing_extensions import TypeVar 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.core.equipment_container import Feeder +from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal from zepben.evolve.model.cim.iec61970.base.wires.power_transformer import PowerTransformer from zepben.evolve.model.cim.iec61970.base.wires.switch import Switch +from zepben.evolve.services.network.network_service import NetworkService +from zepben.evolve.services.network.tracing.networktrace.conditions.conditions import stop_at_open +from zepben.evolve.services.network.tracing.networktrace.network_trace import NetworkTrace +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 import NetworkService - -__all__ = ["FindSwerEquipment"] - -from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep +if TYPE_CHECKING: + from logging import Logger T = TypeVar -from zepben.evolve.services.network.tracing.networktrace.network_trace import NetworkTrace - -from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators -from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing +__all__ = ["FindSwerEquipment"] class FindSwerEquipment: @@ -31,7 +32,14 @@ class FindSwerEquipment: A class which can be used for finding the SWER equipment in a [NetworkService] or [Feeder]. """ - async def find(self, to_process: Union[NetworkService, Feeder], network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL) -> Set[ConductingEquipment]: + def __init__(self, debug_logger: Logger = None): + self._debug_logger = debug_logger + + async def find( + self, + to_process: Union[NetworkService, Feeder], + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL + ) -> Set[ConductingEquipment]: """ Convenience method to call out to `find_all` or `find_on_feeder` based on the class type of `to_process` @@ -40,12 +48,19 @@ async def find(self, to_process: Union[NetworkService, Feeder], network_state_op :return: A `Set` of `ConductingEquipment` on `Feeder` that is SWER, or energised via SWER. """ + if isinstance(to_process, Feeder): return set(await self.find_on_feeder(to_process, network_state_operators)) elif isinstance(to_process, NetworkService): return set([item async for item in self.find_all(to_process, network_state_operators)]) - - async def find_all(self, network_service: NetworkService, network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL) -> AsyncGenerator[ConductingEquipment, None]: + else: + raise NotImplementedError + + async def find_all( + self, + network_service: NetworkService, + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL + ) -> AsyncGenerator[ConductingEquipment, None]: """ Find the `ConductingEquipment` on any `Feeder` in a `NetworkService` which is SWER. This will include any equipment on the LV network that is energised via SWER. @@ -55,11 +70,16 @@ async def find_all(self, network_service: NetworkService, network_state_operator :return: A `Set` of `ConductingEquipment` on `Feeder` that is SWER, or energised via SWER. """ + for feeder in network_service.objects(Feeder): for item in await self.find_on_feeder(feeder, network_state_operators): yield item - async def find_on_feeder(self, feeder: Feeder, network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL) -> Set[ConductingEquipment]: + async def find_on_feeder( + self, + feeder: Feeder, + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL + ) -> Set[ConductingEquipment]: """ Find the `ConductingEquipment` on a `Feeder` which is SWER. This will include any equipment on the LV network that is energised via SWER. @@ -68,10 +88,11 @@ async def find_on_feeder(self, feeder: Feeder, network_state_operators: NetworkS :return: A `Set` of `ConductingEquipment` on `feeder` that is SWER, or energised via SWER. """ + swer_equipment: Set[ConductingEquipment] = set() # We will add all the SWER transformers to the swer_equipment list before starting any traces to prevent tracing though them by accident. In - # order to do this, we collect the sequence to a list to change the iteration order. + # order to do this, we collect the sequence to a list to change the iteration order. for equipment in network_state_operators.get_equipment(feeder): if isinstance(equipment, PowerTransformer): if _has_swer_terminal(equipment) and _has_non_swer_terminal(equipment): @@ -79,18 +100,26 @@ async def find_on_feeder(self, feeder: Feeder, network_state_operators: NetworkS await self._trace_from(network_state_operators, equipment, swer_equipment) return swer_equipment - @staticmethod - def _create_trace(state_operators: NetworkStateOperators) -> NetworkTrace[T]: - return Tracing.network_trace(state_operators).add_condition(state_operators.stop_at_open()) + def _create_trace(self, state_operators: Type[NetworkStateOperators]) -> NetworkTrace[T]: + return Tracing.network_trace( + network_state_operators=state_operators, + debug_logger=self._debug_logger, + name=f'FindSwerEquipment({state_operators.description})' + ).add_condition(stop_at_open()) - async def _trace_from(self, state_operators: NetworkStateOperators, transformer: PowerTransformer, swer_equipment: Set[ConductingEquipment]): + async def _trace_from(self, state_operators: Type[NetworkStateOperators], transformer: PowerTransformer, swer_equipment: Set[ConductingEquipment]): # Trace from any SWER terminals. await self._trace_swer_from(state_operators, transformer, swer_equipment) # Trace from any LV terminals. await self._trace_lv_from(state_operators, transformer, swer_equipment) - async def _trace_swer_from(self, state_operators: NetworkStateOperators, transformer: PowerTransformer, swer_equipment: Set[ConductingEquipment]): + async def _trace_swer_from( + self, + state_operators: Type[NetworkStateOperators], + transformer: PowerTransformer, + swer_equipment: Set[ConductingEquipment] + ): def condition(next_step, nctx, step, ctx): if _is_swer_terminal(next_step.path.to_terminal) or isinstance(next_step.path.to_equipment, Switch): @@ -106,8 +135,12 @@ def condition(next_step, nctx, step, ctx): trace.reset() await trace.run(it, None) - - async def _trace_lv_from(self, state_operators: NetworkStateOperators, transformer: PowerTransformer, swer_equipment: Set[ConductingEquipment]): + async def _trace_lv_from( + self, + state_operators: Type[NetworkStateOperators], + transformer: PowerTransformer, + swer_equipment: Set[ConductingEquipment] + ): def condition(next_step, nctx, step, ctx): if 1 <= next_step.path.to_equipment.base_voltage_value <= 1000: @@ -124,14 +157,18 @@ def condition(next_step, nctx, step, ctx): trace.reset() await trace.run(terminal, None) + def _is_swer_terminal(terminal: Terminal) -> bool: return terminal.phases.num_phases == 1 + def _is_non_swer_terminal(terminal: Terminal) -> bool: return terminal.phases.num_phases > 1 + def _has_swer_terminal(ce: ConductingEquipment) -> bool: return any(_is_swer_terminal(it) for it in ce.terminals) + def _has_non_swer_terminal(ce: ConductingEquipment) -> bool: return any(_is_non_swer_terminal(it) for it in ce.terminals) 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 ea65d4026..d8f4c8d6a 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 @@ -34,10 +34,10 @@ class EquipmentTreeBuilder(StepActionWithContextValue): >>> .add_step_action(tree_builder)).run() """ - _roots: dict[ConductingEquipment, EquipmentTreeNode]={} + _roots: dict[ConductingEquipment, EquipmentTreeNode] = {} def __init__(self): - self.key = str(uuid.uuid4()) + super().__init__(key=str(uuid.uuid4())) @property def roots(self) -> Generator[TreeNode[ConductingEquipment], None, None]: @@ -50,16 +50,22 @@ def compute_initial_value(self, item: NetworkTraceStep[Any]) -> EquipmentTreeNod self._roots[item.path.to_equipment] = node return node - def compute_next_value_typed(self, next_item: NetworkTraceStep[Any], current_item: NetworkTraceStep[Any], current_value: EquipmentTreeNode) -> EquipmentTreeNode: + def compute_next_value( + self, + next_item: NetworkTraceStep[Any], + current_item: NetworkTraceStep[Any], + current_value: EquipmentTreeNode + ) -> EquipmentTreeNode: + if next_item.path.traced_internally: return current_value else: return TreeNode(next_item.path.to_equipment, current_value) - def apply(self, item: NetworkTraceStep[Any], context: StepContext): + def _apply(self, item: NetworkTraceStep[Any], context: StepContext): current_node: TreeNode = self.get_context_value(context) if current_node.parent: current_node.parent.add_child(current_node) def clear(self): - self._roots.clear() \ No newline at end of file + self._roots.clear() 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 174becbba..84944d505 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 @@ -6,8 +6,8 @@ from typing import Generic, TypeVar, TYPE_CHECKING, Type +from zepben.evolve.services.network.tracing.traversal.context_value_computer import ContextValueComputer from zepben.evolve.services.network.tracing.traversal.stop_condition import StopConditionWithContextValue -from zepben.evolve.services.network.tracing.traversal.context_value_computer import TypedContextValueComputer if TYPE_CHECKING: from zepben.evolve import ConductingEquipment, StepContext, NetworkTraceStep @@ -18,10 +18,10 @@ __all__ = ['EquipmentTypeStepLimitCondition'] -class EquipmentTypeStepLimitCondition(StopConditionWithContextValue[T, U], Generic[T, U]): +class EquipmentTypeStepLimitCondition(StopConditionWithContextValue[T], Generic[T, U]): def __init__(self, limit: int, equipment_type: Type[ConductingEquipment]): StopConditionWithContextValue.__init__(self, self.should_stop) - TypedContextValueComputer.__init__(self, f'sdk:{equipment_type.name}Count') + ContextValueComputer.__init__(self, f'sdk:{equipment_type.name}Count') self.limit = limit self.equipment_type = equipment_type @@ -31,7 +31,7 @@ def should_stop(self, item: NetworkTraceStep[T], context: StepContext) -> bool: def compute_initial_value(self, item: NetworkTraceStep[T]) -> int: return 0 - def compute_next_value_typed(self, next_item: NetworkTraceStep[T], current_item: NetworkTraceStep[T], current_value: int) -> int: + def compute_next_value(self, next_item: NetworkTraceStep[T], current_item: NetworkTraceStep[T], current_value: int) -> int: if next_item.path.traced_internally: return current_value if self.matches_equipment_type(next_item.path.to_equipment): 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 33ad59dd5..4e560b96d 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py @@ -2,63 +2,65 @@ # 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 inspect from collections.abc import Callable from functools import singledispatchmethod +from logging import Logger from typing import TypeVar, Union, Generic, Set, Type, Generator, FrozenSet -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 +from zepben.evolve.model.cim.iec61970.base.wires.aclinesegment import AcLineSegment +from zepben.evolve.model.cim.iec61970.base.wires.clamp import Clamp from zepben.evolve.model.cim.iec61970.base.wires.single_phase_kind import SinglePhaseKind - +from zepben.evolve.services.network.tracing.connectivity.nominal_phase_path import NominalPhasePath 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.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.conditions.network_trace_stop_condition import NetworkTraceStopCondition, ShouldStop +from zepben.evolve.services.network.tracing.networktrace.network_trace_action_type import NetworkTraceActionType, CanActionItem 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 import TraversalQueue 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, StopConditionTypes -from zepben.evolve.services.network.tracing.traversal.queue import TraversalQueue -from zepben.evolve.services.network.tracing.connectivity.nominal_phase_path import NominalPhasePath T = TypeVar('T') D = TypeVar('D') +__all__ = ['NetworkTrace'] + class NetworkTrace(Traversal[NetworkTraceStep[T], 'NetworkTrace[T]'], Generic[T]): """ - A [Traversal] implementation specifically designed to trace connected [Terminal]s of [ConductingEquipment] in a network. + A :class:`Traversal` implementation specifically designed to trace connected :class:`Terminal`s of :class:`ConductingEquipment` in a network. This trace manages the complexity of network connectivity, especially in cases where connectivity is not straightforward, - such as with [BusbarSection]s and [Clamp]s. It checks the in service flag of equipment and only steps to equipment that is marked as in service. + such as with :class:`BusbarSection`s and :class:`Clamp`s. It checks the in service flag of equipment and only steps to equipment that is marked as in service. It also provides the optional ability to trace only specific phases. - Steps are represented by a [NetworkTraceStep], which contains a [NetworkTraceStep.Path] and allows associating arbitrary data with each step. - The arbitrary data for each step is computed via a [ComputeData] or [ComputeDataWithPaths] function provided at construction. + Steps are represented by a :class:`NetworkTraceStep`, which contains a :class:`NetworkTraceStep.Path` and allows associating arbitrary data with each step. + The arbitrary data for each step is computed via a :class:`ComputeData` or :class:`ComputeDataWithPaths` function provided at construction. The trace invokes these functions when queueing each item and stores the result with the next step. When traversing, this trace will step on every connected terminal, as long as they match all the traversal conditions. Each step is classified as either an external step or an internal step: - - **External Step**: Moves from one terminal to another with different [Terminal.conductingEquipment]. - - **Internal Step**: Moves between terminals within the same [Terminal.conductingEquipment]. + - **External Step**: Moves from one terminal to another with different ``Terminal.conducting_equipment``. + - **Internal Step**: Moves between terminals within the same ``Terminal.conducting_equipment``. - Often, you may want to act upon a [ConductingEquipment] only once, rather than multiple times for each internal and external terminal step. - To achieve this, set [actionType] to [NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT]. With this type, the trace will only call step actions and - conditions once for each [ConductingEquipment], regardless of how many terminals it has. However, queue conditions can be configured to be called + Often, you may want to act upon a :class:`ConductingEquipment` only once, rather than multiple times for each internal and external terminal step. + To achieve this, set ``action_type`` to ``NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT``. With this type, the trace will only call step actions and + conditions once for each :class:`ConductingEquipment`, regardless of how many terminals it has. However, queue conditions can be configured to be called differently for each condition as continuing the trace can rely on different conditions based on an external or internal step. For example, not - queuing past open switches should happen on an internal step, thus if the trace is configured with FIRST_STEP_ON_EQUIPMENT, it will by default only - action the first external step to each equipment, and thus the provided [Conditions.stopAtOpen] condition overrides the default behaviour such that + queuing past open switches should happen on an internal step, thus if the trace is configured with ``FIRST_STEP_ON_EQUIPMENT``, it will by default only + action the first external step to each equipment, and thus the provided `Conditions.stopAtOpen` condition overrides the default behaviour such that it is called on all internal steps. - The network trace is state-aware by requiring an instance of [NetworkStateOperators]. + The network trace is state-aware by requiring an instance of :class:`NetworkStateOperators`. This allows traversal conditions and step actions to query and act upon state-based properties and functions of equipment in the network when required. 'Branching' traversals are also supported allowing tracing both ways around loops in the network. When using a branching instance, a new 'branch' @@ -67,137 +69,225 @@ class NetworkTrace(Traversal[NetworkTraceStep[T], 'NetworkTrace[T]'], Generic[T] a branch will be created for each terminal. If you do not need to trace loops both ways or have no loops, do not use a branching instance as it is less efficient than the non-branching one. - To create instances of this class, use the factory methods provided in the [Tracing] object. + To create instances of this class, use the factory methods provided in the :class:`Tracing` object. """ - def __init__(self, - network_state_operators: Type[NetworkStateOperators], - queue_type: Union[Traversal.BasicQueueType, Traversal.BranchingQueueType], - parent: 'NetworkTrace[T]'=None, - action_type: NetworkTraceActionType=None - ): - + def __init__( + self, + network_state_operators: Type[NetworkStateOperators], + queue_type: Union[Traversal.BasicQueueType, Traversal.BranchingQueueType], + parent: 'NetworkTrace[T]' = None, + action_type: CanActionItem = None, + debug_logger: Logger = None, + name: str = None, + ): + + if name is None: + raise ValueError('name can not be None') + self.name = name 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 self._tracker = NetworkTraceTracker() - super().__init__(self._queue_type, parent) + super().__init__(self._queue_type, parent=parent, debug_logger=debug_logger) @classmethod - def non_branching(cls, - 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, - compute_data_with_action_type(compute_data, action_type) - ), queue), - None, - action_type) + def non_branching( + cls, + network_state_operators: Type[NetworkStateOperators], + queue: TraversalQueue[NetworkTraceStep[T]], + action_type: CanActionItem, + name: str, + compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]], + debug_logger=None, + ) -> 'NetworkTrace[T]': + + return cls( + network_state_operators, + Traversal.BasicQueueType(NetworkTraceQueueNext.Basic(network_state_operators, compute_data_with_action_type(compute_data, action_type)), queue), + None, + action_type, + debug_logger, + name, + ) @classmethod - def branching(cls, - 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, - compute_data_with_action_type(compute_data, action_type) - ), queue_factory, branch_queue_factory), - parent, - action_type) + def branching( + cls, + network_state_operators: Type[NetworkStateOperators], + queue_factory: Callable[[], TraversalQueue[T]], + branch_queue_factory: Callable[[], TraversalQueue['NetworkTrace[T]']], + action_type: CanActionItem, + name: str, + parent: 'NetworkTrace[T]' = None, + compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]] = None, + debug_logger: Logger = None, + ) -> 'NetworkTrace[T]': + + return cls( + network_state_operators, + Traversal.BranchingQueueType( + NetworkTraceQueueNext.Branching(network_state_operators, compute_data_with_action_type(compute_data, action_type)), + queue_factory, + branch_queue_factory, + ), + parent, + action_type, + debug_logger, + name, + ) @singledispatchmethod - def add_start_item(self, start: Union[Terminal, ConductingEquipment], data: T=None, phases: PhaseCode=None) -> "NetworkTrace[T]": + def add_start_item(self, start: Union[Terminal, ConductingEquipment, NetworkTraceStep.Path], 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. - - All terminals of the given [ConductingEquipment] as starting points in the trace, with the associated data. + Depending on the type of `start` adds one of the following as starting points in the trace, along + with the associated data: + + - A starting `Terminal` + - All terminals of the given :class:`ConductingEquipment`. + - All terminals of the given :class:`AcLineSegment`. + - The :class:`NetworkTraceStep.Path` passed in. Tracing will be only external from this terminal and not trace internally back through its conducting equipment. - :param start: The starting [Terminal] or [ConductingEquipment] for the trace. + :param start: The starting item for the trace. :param data: The data associated with the start step. :param phases: Phases to trace; `None` to ignore phases. + + :returns: This `NetworkTrace` instance """ + raise Exception('INTERNAL ERROR:: unexpected add_start_item params') @add_start_item.register def _(self, start: ConductingEquipment, data=None, phases=None): + """ + Adds all terminals of the given :class:`ConductingEquipment` as starting points in the trace, with the associated data. + Tracing will be only external from each terminal and not trace internally back through the conducting equipment. + + :param start: The starting equipment whose terminals will be added to the trace + :param data: The data associated with the start step. + :param phases: Phases to trace; `None` to ignore phases. + + :returns: This :class:`NetworkTrace` instance + """ + # 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) - + self._add_start_item(it, data=data, phases=phases) return self @add_start_item.register def _(self, start: Terminal, data=None, phases=None): + """ + Adds a starting :class:`Terminal` to the trace with the associated step data. Tracing will be only external from this + terminal and not trace internally back through its conducting equipment. + + :param start: The starting `Terminal` for the trace. + :param data: The data associated with the start step. + :param phases: Phases to trace; `None` to ignore phases. + + :returns: This :class:`NetworkTrace` instance + """ + # 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) + self._add_start_item(start, data=data, phases=phases, traversed_ac_line_segment=traversed_ac_line_segment) return self @add_start_item.register def _(self, start: AcLineSegment, data=None, phases=None): + """ + Adds all terminals of the given :class:`AcLineSegment` as starting points in the trace, with the associated data. + Tracing will be only external from each terminal and not trace internally back through the `AcLineSegment`. + + :param start: The starting `AcLineSegment` whose terminals will be added to the trace + :param data: The data associated with the start step. + :param phases: Phases to trace; `None` to ignore phases. + + :returns: This :class:`NetworkTrace` instance + """ + # 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 _terminal in start.terminals: + yield _terminal for clamp in start.clamps: - for terminal in clamp.terminals: - yield terminal + for _terminal in clamp.terminals: + yield _terminal break for cut in start.cuts: - for terminal in cut.terminals: - yield terminal - + for _terminal in cut.terminals: + yield _terminal for terminal in start_terminals(): - self._add_start_item(terminal, data, phases, start) + self._add_start_item(terminal, data=data, phases=phases, traversed_ac_line_segment=start) + return self + @add_start_item.register + def _(self, start: NetworkTraceStep.Path, data: T, phases=None): + if phases: + raise ValueError('starting from a NetworkTraceStep.Path does not support specifying phases') + self._add_start_item(start, data=data) + return self + + def _add_start_item( + self, start: Union[Terminal, NetworkTraceStep.Path], data: T = None, phases: PhaseCode = None, traversed_ac_line_segment: AcLineSegment = None + ): + """ + To be called by self.add_start_item(), this method builds the start :class:`NetworkTraceStep.Path`s for the start item + and adds it to the :class:`Traversal` - def _add_start_item(self, - start: Terminal=None, - data: T=None, - phases: PhaseCode=None, - traversed_ac_line_segment: AcLineSegment=None): + If `start` is a `NetworkTraceStep.Path`, [`phases`, `traversed_ac_line_segment`] will all be ignored. + + :param start: The starting :class:`Terminal` or `NetworkTraceStep.Path` to be added to the trace + :param data: The data associated with the start `Terminal`. + :param phases: Phases to trace; `None` to ignore phases. + :param traversed_ac_line_segment: The :class:`AcLineSegment` that was just traversed + + :returns: This `NetworkTrace` instance + """ + + if isinstance(start, NetworkTraceStep.Path): + if any([phases, traversed_ac_line_segment]): + raise ValueError('phases and traversed_ac_line_segment are all ignored when start is a NetworkTraceStep.Path') + start_path = start + else: + start_path = NetworkTraceStep.Path(start, start, traversed_ac_line_segment, self.start_nominal_phase_path(phases)) - 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]": + async def run( + self, + start: Union[ConductingEquipment, Terminal, NetworkTraceStep.Path] = None, + data: T = None, + phases: PhaseCode = None, + can_stop_on_start_item: bool = True, + ) -> "NetworkTrace[T]": """ - Runs the network trace starting from `start` + Runs the network trace starting from ``start`` + + Depending on the type of ``start``, this will either start from:: - 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 :class:`Terminal` or :class:`ConductingEquipment` for the trace. :param data: The data associated with the start step. - :param phases: Phases to trace; `None` to ignore phases. + :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. """ + if start is not None: self.add_start_item(start, data, phases) @@ -205,76 +295,88 @@ async def run(self, start: Union[ConductingEquipment, Terminal]=None, data: T=No return self @singledispatchmethod - def add_condition(self, condition: QueueCondition[T]) -> "NetworkTrace[T]": - + def add_condition(self, condition: QueueCondition[T], **kwargs) -> "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 - + 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 + :keyword allow_re_wrapping: Allow rewrapping of :class:`StopCondition`s with debug logging + :returns: This :class:`NetworkTrace` instance """ - return super().add_condition(condition) + + return super().add_condition(condition, **kwargs) @add_condition.register - def _(self, condition: Callable): + def _(self, condition: Callable, **kwargs): """ - Adds a traversal condition to the trace using the trace's [NetworkStateOperators] as the receiver. + Adds a traversal condition to the trace using the trace's :class:`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.stop_at_open] factory, you can use: + For example, to configure the trace to stop at open points using the :meth:`Conditions.stop_at_open` factory, you can use: + + .. code-block:: - >>> from zepben.evolve import stop_at_open - >>> NetworkTrace().add_condition(stop_at_open()) + from zepben.evolve import stop_at_open + NetworkTrace().add_condition(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) + if len(inspect.getfullargspec(condition).args) == 1: # Catches DSL Style lambda conditions from zepben.evolve.Conditions + return self.add_condition(condition(self.network_state_operators), **kwargs) + return super().add_condition(condition, **kwargs) @singledispatchmethod - def add_queue_condition(self, condition: NetworkTraceQueueCondition[NetworkTraceStep[T]], step_type: NetworkTraceStep.Type=None) -> "NetworkTrace[T]": + def add_queue_condition( + self, condition: NetworkTraceQueueCondition[NetworkTraceStep[T]], step_type: NetworkTraceStep.Type = None, **kwargs + ) -> "NetworkTrace[T]": """ - Adds a `QueueCondition` to the traversal. However, before registering it with the traversal, it will make sure that the queue condition + Adds a :class:`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: - - `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. + - ``step_type`` is ``NetworkTraceActionType.ALL_STEPS`` the condition will be checked on all steps. + - ``step_type`` is ``NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT`` the condition will be checked on external steps. - However, if the `condition` is an instance of `NetworkTraceQueueCondition` the `NetworkTraceQueueCondition.step_type` will be honoured. + However, if the `condition` is an instance of :class:`NetworkTraceQueueCondition` the ``NetworkTraceQueueCondition.step_type`` will be honoured. :param condition: The queue condition to add. - :returns: This `NetworkTrace` instance + :param step_type: `NetworkTraceStepType` value. + :keyword allow_re_wrapping: Allow rewrapping of :class:`QueueCondition`s with debug logging + :returns: This :class:`NetworkTrace` instance """ - return super().add_queue_condition(condition) + + return super().add_queue_condition(condition, **kwargs) @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)) + def _(self, condition: Callable, step_type: NetworkTraceStep.Type = None, **kwargs): + return self.add_queue_condition(NetworkTraceQueueCondition(default_condition_step_type(self._action_type) or step_type, condition), **kwargs) @singledispatchmethod - def add_stop_condition(self, condition: StopConditionTypes, step_type: NetworkTraceStep.Type=None) -> "NetworkTrace[T]": + def add_stop_condition(self, condition: StopConditionTypes, step_type: NetworkTraceStep.Type = None, **kwargs) -> "NetworkTrace[T]": """ - Adds a `StopCondition` to the traversal. However, before registering it with the traversal, it will make sure that the queue condition + Adds a :class:`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. + - ``step_type`` is ``NetworkTraceActionType.ALL_STEPS`` the condition will be checked on all steps. + - ``step_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. + However, if the `condition` is an instance of :class:`NetworkTraceStopCondition` the ``NetworkTraceStopCondition.step_type`` will be honoured. :param condition: The stop condition to add. - :returns: This `NetworkTrace` instance + :param step_type: `NetworkTraceStepType` value. + :keyword allow_re_wrapping: Allow rewrapping of :class:`StopCondition`s with debug logging + :returns: This :class:`NetworkTrace` instance """ - return super().add_stop_condition(condition) + + return super().add_stop_condition(condition, **kwargs) @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 _(self, condition: ShouldStop, step_type=None, **kwargs): + return self.add_stop_condition(NetworkTraceStopCondition(default_condition_step_type(self._action_type) or step_type, condition), **kwargs) def can_action_item(self, item: T, context: StepContext) -> bool: return self._action_type(item, context, self.has_visited) @@ -289,7 +391,7 @@ def get_derived_this(self) -> 'NetworkTrace[T]': return self def create_new_this(self) -> 'NetworkTrace[T]': - return NetworkTrace(self.network_state_operators, self._queue_type, self, self._action_type) + return NetworkTrace(self.network_state_operators, self._queue_type, self, self._action_type, debug_logger=None, name=self.name) @staticmethod def start_nominal_phase_path(phases: PhaseCode) -> Set[NominalPhasePath]: @@ -305,11 +407,11 @@ def has_visited(self, terminal: Terminal, phases: FrozenSet[SinglePhaseKind]) -> def visit(self, terminal: Terminal, phases: FrozenSet[SinglePhaseKind]) -> bool: if self.parent and self.parent.has_visited(terminal, phases): - return False + return False return self._tracker.visit(terminal, phases) -def default_condition_step_type(step_type): +def default_condition_step_type(step_type: CanActionItem): if step_type is None: return False if step_type == NetworkTraceActionType.ALL_STEPS: @@ -319,22 +421,28 @@ def default_condition_step_type(step_type): raise Exception('step doesnt match expected types') -def compute_data_with_action_type(compute_data: ComputeData[T], action_type: NetworkTraceActionType) -> ComputeData[T]: +def compute_data_with_action_type(compute_data: ComputeData[T], action_type: CanActionItem) -> ComputeData[T]: if action_type == NetworkTraceActionType.ALL_STEPS: return compute_data elif action_type == NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT: - return ComputeData(lambda current_step, current_context, next_path: - current_step.data if next_path.traced_internally else compute_data.compute_next(current_step, current_context, next_path) + return ComputeData( + lambda current_step, current_context, next_path: ( + current_step.data if next_path.traced_internally else compute_data.compute_next(current_step, current_context, next_path) + ) ) raise Exception(f'{action_type.__class__}: step doesnt match expected types') + def with_paths_with_action_type(self, action_type: NetworkTraceActionType) -> ComputeDataWithPaths[T]: if action_type == NetworkTraceActionType.ALL_STEPS: return self elif action_type == NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT: - return ComputeDataWithPaths(lambda current_step, current_context, next_path, next_paths: - current_step.data if next_path.traced_internally else self.compute_next(current_step, current_context, next_path, next_paths) + return ComputeDataWithPaths( + lambda current_step, current_context, next_path, next_paths: ( + current_step.data if next_path.traced_internally else self.compute_next(current_step, current_context, next_path, next_paths) + ) ) raise Exception('step doesnt match expected types') + ComputeDataWithPaths[T].with_action_type = with_paths_with_action_type 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 005a518a7..55cb3d82e 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,7 +6,7 @@ from dataclasses import dataclass, field from enum import Enum -from typing import Set, Generic, TypeVar, TYPE_CHECKING, Optional, List, FrozenSet +from typing import Set, Generic, TypeVar, TYPE_CHECKING, Optional, FrozenSet from zepben.evolve.services.network.tracing.connectivity.nominal_phase_path import NominalPhasePath @@ -18,15 +18,18 @@ T = TypeVar('T') +__all__ = ['NetworkTraceStep'] + + class NetworkTraceStep(Generic[T]): """ Represents a single step in a network trace, containing information about the path taken and associated data. `T` The type of additional data associated with the trace step. - :param path: The path representing the transition from one terminal to another. - :param num_terminal_steps: The count of terminals stepped on along this path. - :param num_equipment_steps: The count of equipment stepped on along this path. - :param data: Additional data associated with this step in the trace. + :var path: The path representing the transition from one terminal to another. + :var num_terminal_steps: The count of terminals stepped on along this path. + :var num_equipment_steps: The count of equipment stepped on along this path. + :var data: Additional data associated with this step in the trace. """ @dataclass @@ -34,17 +37,20 @@ 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 `from_terminal` - or `to_terminal` have `None` 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. - 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]. + 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 :class:`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. + :var from_terminal: The terminal that was stepped from. + :var to_terminal: The terminal that was stepped to. + :var traversed_ac_line_segment: If the ``from_terminal`` and ``to_terminal`` path was via an :class:`AcLineSegment`, this is + the segment that was traversed + :var 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 traversed_ac_line_segment: Optional[AcLineSegment] = field(default=None) @@ -57,9 +63,7 @@ def to_phases_set(self) -> FrozenSet[SinglePhaseKind]: @property def from_equipment(self) -> ConductingEquipment: - """ - The conducting equipment associated with `self.from_terminal`. - """ + """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") @@ -67,9 +71,7 @@ def from_equipment(self) -> ConductingEquipment: @property def to_equipment(self) -> ConductingEquipment: - """ - The conducting equipment associated with `self.to_terminal`. - """ + """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") @@ -77,16 +79,12 @@ 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. - """ + """``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. - """ + """``True`` if the from and to terminals belong to different equipment; ``False`` otherwise.""" return not self.traced_internally @property @@ -96,7 +94,6 @@ def did_traverse_ac_line_segment(self) -> bool: 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')) def __init__(self, path: Path, num_terminal_steps: int, num_equipment_steps: int, data: T): @@ -107,12 +104,21 @@ def __init__(self, path: Path, num_terminal_steps: int, num_equipment_steps: int 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. + 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. - Returns [Type.INTERNAL] with [Path.tracedInternally] is true, [Type.EXTERNAL] when [Path.tracedExternally] is true + Returns ``Type.INTERNAL`` with ``Path.tracedInternally`` is true, ``Type.EXTERNAL`` when ``Path.tracedExternally`` is true """ + return self.Type.INTERNAL if self.path.traced_internally else self.Type.EXTERNAL def next_num_terminal_steps(self): return self.num_terminal_steps + 1 + + def __getitem__(self, item): + """Convenience method to access this ``NetworkTraceStep`` as a tuple of (self.path, self.data)""" + return (self.path, self.data)[item] + + def __str__(self): + return f"NetworkTraceStep({', '.join('{}={}'.format(*i) for i in vars(self).items())})" 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 80aebb851..422b54b26 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 @@ -9,7 +9,6 @@ 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 @@ -21,18 +20,22 @@ CurrentOpenStateOperators from zepben.evolve.services.network.tracing.networktrace.operators.phase_state_operators import PhaseStateOperators, NormalPhaseStateOperators, \ CurrentPhaseStateOperators +from zepben.evolve.util import classproperty 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): +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. @@ -47,6 +50,8 @@ class NetworkStateOperators(OpenStateOperators, or creating redundant trace implementations for different network states. """ + description: str + @classproperty def NORMAL(cls) -> Type['NormalNetworkStateOperators']: return NormalNetworkStateOperators @@ -61,16 +66,20 @@ def next_paths(cls, path: NetworkTraceStep.Path) -> Generator[NetworkTraceStep.P pass -class NormalNetworkStateOperators(NetworkStateOperators, - NormalOpenStateOperators, - NormalFeederDirectionStateOperations, - NormalEquipmentContainerStateOperators, - NormalInServiceStateOperators, - NormalPhaseStateOperators): +class NormalNetworkStateOperators( + NetworkStateOperators, + NormalOpenStateOperators, + NormalFeederDirectionStateOperations, + NormalEquipmentContainerStateOperators, + NormalInServiceStateOperators, + NormalPhaseStateOperators +): """ Instance that operates on the normal state of network objects. """ + description = 'normal' + CURRENT = False NORMAL = True @@ -83,16 +92,21 @@ def network_trace_step_path_provider(cls): 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): + +class CurrentNetworkStateOperators( + NetworkStateOperators, + CurrentOpenStateOperators, + CurrentFeederDirectionStateOperations, + CurrentEquipmentContainerStateOperators, + CurrentInServiceStateOperators, + CurrentPhaseStateOperators +): """ Instance that operates on the current state of network objects. """ + description = 'current' + CURRENT = True NORMAL = False @@ -104,4 +118,3 @@ def network_trace_step_path_provider(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) - diff --git a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py index 1dd794dd3..b0dd041b9 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py @@ -2,11 +2,12 @@ # 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 logging import Logger 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 -from zepben.evolve.services.network.tracing.networktrace.network_trace_action_type import NetworkTraceActionType +from zepben.evolve.services.network.tracing.networktrace.network_trace_action_type import NetworkTraceActionType, CanActionItem 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.traversal.queue import TraversalQueue @@ -16,75 +17,116 @@ class Tracing: @staticmethod - 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 - ) -> NetworkTrace[T]: + def network_trace( + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL, + action_step_type: CanActionItem = NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, + debug_logger: Logger = None, + name: str = 'NetworkTrace', + queue: TraversalQueue[NetworkTraceStep[T]] = TraversalQueue.depth_first(), + compute_data: Union[ComputeData[T], Callable] = None + ) -> NetworkTrace[T]: """ Creates a `NetworkTrace` that computes contextual data for every step. :param network_state_operators: The state operators to make the NetworkTrace state aware. Defaults to `NetworkStateOperators.NORMAL`. :param action_step_type: The action step type to be applied when the trace steps. Defaults to `NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT`. :param queue: The traversal queue the trace is backed by. Defaults to a depth first queue. - :param compute_data: The computer that provides the [NetworkTraceStep.data] contextual step data for each step in the trace. + :param debug_logger: An optional logger to add information about how the trace is processing items. + :param name: An optional name for your trace that can be used for logging purposes. + :param compute_data: The computer that provides the `NetworkTraceStep.data` contextual step data for each step in the trace. :returns: a new `NetworkTrace` """ + if not isinstance(compute_data, ComputeData): compute_data = ComputeData(compute_data or (lambda *args: None)) - return NetworkTrace.non_branching(network_state_operators, queue, action_step_type, compute_data) + return NetworkTrace.non_branching( + network_state_operators, + queue, + action_step_type, + name, + compute_data, + debug_logger=debug_logger + ) @staticmethod - 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]: + def network_trace_branching( + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL, + action_step_type: CanActionItem = NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, + debug_logger: Logger = None, + name: str = 'NetworkTrace', + 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]: + """ + Creates a branching `NetworkTrace` that computes contextual data for every step. A new 'branch' will be created for each terminal + where the current terminal in the trace will step to two or more terminals. + + :param network_state_operators: The state operators to make the NetworkTrace state aware. Defaults to `NetworkStateOperators.NORMAL`. + :param action_step_type: The action step type to be applied when the trace steps. Defaults to `NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT`. + :param queue_factory: A factory that will produce [TraversalQueue]s used by each branch in the trace to queue steps. Defaults to a factory + the creates depth first queues. + :param branch_queue_factory: A factory that will produce `TraversalQueue`s used by each branch in the trace to queue branches. Defaults + to a factory that creates breadth first queues. + :param debug_logger: An optional logger to add information about how the trace is processing items. + :param name: An optional name for your trace that can be used for logging purposes. + :param compute_data: The computer that provides the `NetworkTraceStep.data` contextual step data for each step in the trace. + + :returns: a new `NetworkTrace` + """ if not isinstance(compute_data, ComputeData): compute_data = ComputeData(compute_data or (lambda *args: None)) - return NetworkTrace.branching(network_state_operators, queue_factory, branch_queue_factory, action_step_type, None, compute_data) + return NetworkTrace.branching( + network_state_operators, + queue_factory, + branch_queue_factory, + action_step_type, + name, + None, + compute_data, + debug_logger=debug_logger + ) @staticmethod - def set_direction(): + def set_direction(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.feeder.set_direction import SetDirection - return SetDirection() + return SetDirection(debug_logger=debug_logger) @staticmethod - def clear_direction(): + def clear_direction(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.feeder.clear_direction import ClearDirection - return ClearDirection() + return ClearDirection(debug_logger=debug_logger) @staticmethod - def assign_equipment_to_feeders(): + def assign_equipment_to_feeders(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.feeder.assign_to_feeders import AssignToFeeders - return AssignToFeeders() + return AssignToFeeders(debug_logger=debug_logger) @staticmethod - def assign_equipment_to_lv_feeders(): + def assign_equipment_to_lv_feeders(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.feeder.assign_to_lv_feeders import AssignToLvFeeders - return AssignToLvFeeders() + return AssignToLvFeeders(debug_logger=debug_logger) @staticmethod - def set_phases(): + def set_phases(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.phases.set_phases import SetPhases - return SetPhases() + return SetPhases(debug_logger=debug_logger) @staticmethod - def remove_phases(): + def remove_phases(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.phases.remove_phases import RemovePhases - return RemovePhases() + return RemovePhases(debug_logger=debug_logger) @staticmethod - def phase_inferrer(): + def phase_inferrer(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.phases.phase_inferrer import PhaseInferrer - return PhaseInferrer() + return PhaseInferrer(debug_logger=debug_logger) @staticmethod - def find_swer_equipment(): + def find_swer_equipment(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.find_swer_equipment import FindSwerEquipment - return FindSwerEquipment() + return FindSwerEquipment(debug_logger=debug_logger) 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 955daaf07..0ffc0c19a 100644 --- a/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py +++ b/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py @@ -1,15 +1,19 @@ -# 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/. -import logging +from __future__ import annotations + from dataclasses import dataclass -from typing import Dict, Callable, List, Set, Awaitable, Type +from typing import Dict, Callable, List, Set, Awaitable, Type, TYPE_CHECKING from zepben.evolve import Terminal, SinglePhaseKind, ConductingEquipment, NetworkService, \ FeederDirection, X_PRIORITY, Y_PRIORITY, is_before, is_after -from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators +from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing + +if TYPE_CHECKING: + from logging import Logger __all__ = ["PhaseInferrer"] @@ -19,6 +23,9 @@ class PhaseInferrer: A class that can infer missing phases on a network that has been processed by `SetPhases`. """ + def __init__(self, debug_logger: Logger = None): + self._debug_logger = debug_logger + @dataclass class InferredPhase: def __init__(self, conducting_equipment: ConductingEquipment, suspect: bool): @@ -34,23 +41,24 @@ def description(self) -> str: 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: Type[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`. :param network: The `NetworkService` to infer phases on. :param network_state_operators: The `NetworkStateOperators` to be used when inferring phases """ + tracking: Dict[ConductingEquipment, bool] = {} - await self.PhaseInferrerInternal(network_state_operators).infer_missing_phases(network, tracking) + await self.PhaseInferrerInternal(network_state_operators, self._debug_logger).infer_missing_phases(network, tracking) return [self.InferredPhase(k, v) for k, v in tracking.items()] - class PhaseInferrerInternal: - def __init__(self, state_operators: Type[NetworkStateOperators]): + def __init__(self, state_operators: Type[NetworkStateOperators], debug_logger: Logger = None): self.state_operators = state_operators + self._debug_logger = debug_logger async def infer_missing_phases(self, network: NetworkService, tracking: Dict[ConductingEquipment, bool]): while True: @@ -58,8 +66,8 @@ async def infer_missing_phases(self, network: NetworkService, tracking: Dict[Con 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)) + 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 @@ -104,7 +112,7 @@ def _missing_from_down_filter(self, terminal: Terminal) -> bool: any(not self._has_none_phase(t) for t in terminal.connectivity_node.terminals if (t != terminal) and (FeederDirection.DOWNSTREAM in self.state_operators.get_direction(t))) - ) + ) def _missing_from_any(self, terminals: List[Terminal]) -> List[Terminal]: return [ @@ -182,11 +190,10 @@ async def _infer_xy_phases(self, terminal: Terminal, max_missing_phases: int, tr await self._continue_phases(terminal) return had_changes - async def _continue_phases(self, terminal: Terminal): - set_phases_trace = Tracing.set_phases() + set_phases_trace = Tracing.set_phases(debug_logger=self._debug_logger) for other in terminal.other_terminals(): - await set_phases_trace.run_spread_phases_and_flow(terminal, other, terminal.phases.single_phases, network_state_operators=self.state_operators) + await set_phases_trace.run(other, terminal.phases.single_phases, network_state_operators=self.state_operators, seed_terminal=terminal) @staticmethod def _first_unused(phases: List[SinglePhaseKind], used_phases: Set[SinglePhaseKind], validate: Callable[[SinglePhaseKind], bool]) -> SinglePhaseKind: @@ -195,4 +202,3 @@ def _first_unused(phases: List[SinglePhaseKind], used_phases: Set[SinglePhaseKin return phase return SinglePhaseKind.NONE - 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 7c7850d1d..19cc9591f 100644 --- a/src/zepben/evolve/services/network/tracing/phases/remove_phases.py +++ b/src/zepben/evolve/services/network/tracing/phases/remove_phases.py @@ -1,24 +1,28 @@ -# 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 __future__ import annotations -from typing import Set, Union, Type +from typing import Set, Union, Type, TYPE_CHECKING 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.conditions.conditions import stop_at_open 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.weighted_priority_queue import WeightedPriorityQueue +if TYPE_CHECKING: + from logging import Logger + from zepben.evolve.services.network.tracing.traversal.step_context import StepContext + class EbbPhases: def __init__(self, phases_to_ebb: Set[SinglePhaseKind]): @@ -32,10 +36,27 @@ class RemovePhases(object): This class is backed by a `BranchRecursiveTraversal`. """ - async def run(self, - start: Union[NetworkService, Terminal], - nominal_phases_to_ebb: Union[PhaseCode, SinglePhaseKind]=None, - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + def __init__(self, debug_logger: Logger = None): + self._debug_logger = debug_logger + + async def run( + self, + start: Union[NetworkService, Terminal], + nominal_phases_to_ebb: Union[PhaseCode, SinglePhaseKind] = None, + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL + ): + """ + If nominal_phases_to_ebb is `None` this will remove all phases for all equipment connected + to `start` + If `start` is a: + - `NetworkService` - Remove traced phases from the specified network. + - `Terminal` - Allows the removal of phases from a terminal and the connected equipment chain + + :param start: NetworkService or Terminal to start phase removal + :param nominal_phases_to_ebb: The nominal phases to remove traced phasing from. Defaults to all phases. + :param network_state_operators: The `NetworkStateOperators` to be used when removing phases. + """ + if nominal_phases_to_ebb is None: if isinstance(start, NetworkService): @@ -47,17 +68,39 @@ 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: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + async def _run_with_network(network_service: NetworkService, network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL) -> None: + """ + Remove all traced phases from the specified network. + + :param network_service: The network service to remove traced phasing from. + :param network_state_operators: The `NetworkStateOperators` to be used when removing phases. + """ + for t in network_service.objects(Terminal): t.traced_phases.phase_status = 0 - async def _run_with_terminal(self, terminal: Terminal, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + async def _run_with_terminal(self, terminal: Terminal, network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL): + """ + Allows the removal of traced phases from a terminal and the connected equipment chain + + :param terminal: Removes all nominal phases a terminal traced phases and the connected equipment chain + :param network_state_operators: The `NetworkStateOperators` to be used when removing phases. + """ + 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: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + async def _run_with_phases_to_ebb( + self, + terminal: Terminal, + nominal_phases_to_ebb: Union[PhaseCode, Set[SinglePhaseKind]], + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL): + """ + Allows the removal of traced phases from a terminal and the connected equipment chain + + :param terminal: Terminal to start phase removal + :param nominal_phases_to_ebb: The nominal phases to remove traced phasing from. Defaults to all phases. + :param network_state_operators: The `NetworkStateOperators` to be used when removing phases. + """ 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) @@ -77,17 +120,19 @@ def compute_data(step: NetworkTraceStep[EbbPhases], context: StepContext, next_p async def step_action(nts: NetworkTraceStep, ctx: StepContext): nts.data.ebbed_phases = await self._ebb(state_operators, nts.path.to_terminal, nts.data.phases_to_ebb) - def queue_condition(next_step: NetworkTraceStep, next_ctx: StepContext=None, step: NetworkTraceStep=None, ctx: StepContext=None): + def queue_condition(next_step: NetworkTraceStep, next_ctx: StepContext = None, step: NetworkTraceStep = None, ctx: StepContext = None): return len(next_step.data.phases_to_ebb) > 0 and (step is None or len(step.data.ebbed_phases) > 0) return Tracing.network_trace( network_state_operators=state_operators, action_step_type=NetworkTraceActionType.ALL_STEPS, + debug_logger=self._debug_logger, + name=f'RemovePhases({state_operators.description})', queue=WeightedPriorityQueue.process_queue(lambda it: len(it.data.phases_to_ebb)), compute_data=compute_data - ).add_condition(state_operators.stop_at_open()) \ - .add_step_action(step_action) \ - .add_queue_condition(queue_condition) + ).add_condition(stop_at_open()) \ + .add_step_action(step_action) \ + .add_queue_condition(queue_condition) @staticmethod async def _ebb(state_operators: Type[NetworkStateOperators], terminal: Terminal, phases_to_ebb: Set[SinglePhaseKind]) -> Set[SinglePhaseKind]: 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 4876b68a8..5ecab8ce4 100644 --- a/src/zepben/evolve/services/network/tracing/phases/set_phases.py +++ b/src/zepben/evolve/services/network/tracing/phases/set_phases.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,12 +6,15 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Union, Set, Iterable, List, Type +from functools import singledispatchmethod +from typing import Union, Set, Iterable, List, Type, TYPE_CHECKING, Optional, Callable +from zepben.evolve import PhaseStatus, add_neutral from zepben.evolve.exceptions import TracingException, PhaseException 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.energy_source import EnergySource +from zepben.evolve.model.cim.iec61970.base.wires.power_transformer import PowerTransformer 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.services.network.tracing.connectivity.nominal_phase_path import NominalPhasePath @@ -24,6 +27,9 @@ from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing from zepben.evolve.services.network.tracing.traversal.weighted_priority_queue import WeightedPriorityQueue +if TYPE_CHECKING: + from logging import Logger + __all__ = ["SetPhases"] @@ -33,135 +39,204 @@ class SetPhases: This class is backed by a `NetworkTrace`. """ + def __init__(self, debug_logger: Logger = None): + self._debug_logger = debug_logger + class PhasesToFlow: def __init__(self, nominal_phase_paths: Iterable[NominalPhasePath], step_flowed_phases: bool = False): self.nominal_phase_paths = nominal_phase_paths self.step_flowed_phases = step_flowed_phases + def __str__(self): + return f'PhasesToFlow(nominal_phase_paths={self.nominal_phase_paths}, step_flowed_phases={self.step_flowed_phases})' - async def run(self, - apply_to: Union[NetworkService, Terminal], - phases: Union[PhaseCode, Iterable[SinglePhaseKind]]=None, - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): - - if isinstance(apply_to, NetworkService): - return await self._run(apply_to, network_state_operators) + @singledispatchmethod + async def run( + self, + target: Union[NetworkService, Terminal], + phases: Union[PhaseCode, Iterable[SinglePhaseKind]] = None, + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL + ): + """ - elif isinstance(apply_to, Terminal): - if phases is None: - return await self._run_terminal(apply_to, network_state_operators) + :param target: + :param phases: + :param network_state_operators: The `NetworkStateOperators` to be used when setting phases. + """ - return await self._run_with_phases(apply_to, phases, network_state_operators) + raise ValueError('INTERNAL ERROR: incorrect params') - else: - raise Exception('INTERNAL ERROR: incorrect params') - - async def _run(self, - network: NetworkService, - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + @run.register + async def _( + self, + network: NetworkService, + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL + ): """ - Apply phases from all sources in the network. + Apply phases and flow from all energy sources in the network. + This will apply `Terminal.phases` to all terminals on each `EnergySource` and then flow along the connected network. - @param network: The network in which to apply phases. - """ - trace = await self._create_network_trace(network_state_operators) - for energy_source in network.objects(EnergySource): - for terminal in energy_source.terminals: - self._apply_phases(network_state_operators, terminal, terminal.phases.single_phases) - await self._run_terminal(terminal, network_state_operators, trace) - - async def _run_with_phases(self, - terminal: Terminal, - phases: Union[PhaseCode, Iterable[SinglePhaseKind]], - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + :param network: The network in which to apply phases. + :param network_state_operators: The `NetworkStateOperators` to be used when setting phases. """ - Apply phases from the `terminal`. - @param terminal: The terminal to start applying phases from. - @param phases: The phases to apply. Must only contain ABCN. + def _terminals_from_network(): + for energy_source in network.objects(EnergySource): + for terminal in energy_source.terminals: + self._apply_phases(terminal.phases.single_phases, terminal, network_state_operators) + yield terminal + + await self._run_terminals(_terminals_from_network(), network_state_operators=network_state_operators) + + @run.register + async def _( + self, + start_terminal: Terminal, + phases: Union[PhaseCode, List[SinglePhaseKind], Set[SinglePhaseKind]] = None, + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL, + seed_terminal: Terminal = None + ): """ - def validate_phases(_phases): - if len(_phases) != len(terminal.phases.single_phases): - raise TracingException( - f"Attempted to apply phases [{', '.join(phase.name for phase in phases)}] to {terminal} with nominal phases {terminal.phases.name}. " - f"Number of phases to apply must match the number of nominal phases. Found {len(_phases)}, expected {len(terminal.phases.single_phases)}" - ) - return _phases + Apply phases to the `start_terminal` and flow, optionally specifying a `seed_terminal`. If specified, the `seed_terminal` + and `start_terminal` must have the same `Terminal.conducting_equipment` - if isinstance(phases, PhaseCode): - self._apply_phases(network_state_operators, terminal, validate_phases(phases.single_phases)) + :param start_terminal: The terminal to start applying phases from. + :param phases: The phases to apply. Must only contain ABCN, if None, `SetPhases` will flow phases already set on `start_terminal`. + :param network_state_operators: The `NetworkStateOperators` to be used when setting phases. + :param seed_terminal: The terminal from which to spread the phases from. + """ - elif isinstance(phases, (list, set)): - self._apply_phases(network_state_operators, terminal, validate_phases(phases)) + if phases is None: + # Flow phases already set on the given Terminal + await self._run_terminals([start_terminal], network_state_operators=network_state_operators) - else: - raise Exception(f'INTERNAL ERROR: Phase of type {phases.__class__} is wrong.') + elif isinstance(phases, PhaseCode): + await self.run(start_terminal, phases=phases.single_phases, network_state_operators=network_state_operators) - await self._run_terminal(terminal, network_state_operators) + elif isinstance(phases, (List, Set)): + if seed_terminal: + nominal_phase_paths = self._get_nominal_phase_paths(network_state_operators, seed_terminal, start_terminal, list(phases)) - async def run_spread_phases_and_flow(self, - seed_terminal: Terminal, - start_terminal: Terminal, - phases: List[SinglePhaseKind], - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + if self._flow_phases(network_state_operators, seed_terminal, start_terminal, nominal_phase_paths): + await self.run(start_terminal, network_state_operators=network_state_operators) - nominal_phase_paths = self._get_nominal_phase_paths(network_state_operators, seed_terminal, start_terminal, list(phases)) - 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) + else: + if len(phases) != len(start_terminal.phases.single_phases): + raise TracingException( + f"Attempted to apply phases [{', '.join(phase.name for phase in phases)}] to {start_terminal} with nominal phases {start_terminal.phases.name}. " + f"Number of phases to apply must match the number of nominal phases. Found {len(phases)}, expected {len(start_terminal.phases.single_phases)}" + ) + self._apply_phases(phases, start_terminal, network_state_operators) + await self._run_terminals([start_terminal], network_state_operators=network_state_operators) + else: + raise ValueError('ERROR: phases must either be a PhaseCode, or Union[List, Set]') - async def spread_phases( + def spread_phases( self, from_terminal: Terminal, to_terminal: Terminal, - phases: List[SinglePhaseKind]=None, - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL + phases: List[SinglePhaseKind] = None, + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL ): """ - Apply phases from the `from_terminal` to the `to_terminal`. + Apply nominal phases from the `from_terminal` to the `to_terminal`. :param from_terminal: The terminal to from which to spread phases. :param to_terminal: The terminal to spread phases to. - :param phases: The nominal phases on which to spread phases. + :param phases: The nominal phases on which to spread phases, if None, `SetPhases` will use phases from `from_terminal`. :param network_state_operators: The `NetworkStateOperators` to be used when setting phases. """ - if phases is None: - return await self.spread_phases(from_terminal, to_terminal, from_terminal.phases.single_phases, network_state_operators) - else: - paths = self._get_nominal_phase_paths(network_state_operators, from_terminal, to_terminal, list(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: 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: Type[NetworkStateOperators]) -> NetworkTrace[PhasesToFlow]: - async def step_action(nts, ctx): - path = nts.path - phases_to_flow = nts.data + + paths = self._get_nominal_phase_paths(network_state_operators, from_terminal, to_terminal, phases or from_terminal.phases.single_phases) + self._flow_phases(network_state_operators, from_terminal, to_terminal, paths) + + async def _run_terminals(self, terminals: Iterable[Terminal], network_state_operators: Type[NetworkStateOperators]): + + partially_energised_transformers: Set[PowerTransformer] = set() + trace = self._create_network_trace(network_state_operators, partially_energised_transformers) + + for terminal in terminals: + await self._run_terminal_trace(terminal, trace) + + # Go back and add any missing phases to transformers that were energised from a downstream side with fewer phases + # when they were in parallel, that successfully energised all the upstream side phases. This setup stops the spread + # from coming back down the upstream (it is fully energised) and processing the transformer correctly. + + if self._debug_logger: + self._debug_logger.info('Reprocessing partially energised transformers...') + + for tx in partially_energised_transformers: + terminals_by_energisation = [(terminal, _not_fully_energised(network_state_operators, terminal)) for terminal in tx.terminals] + if any(energised for _, energised in terminals_by_energisation): + + partially_energised = [] + fully_energised = [] + for terminal, energised in terminals_by_energisation: + if energised: + partially_energised.append(terminal) + else: + fully_energised.append(terminal) + + for partial in partially_energised: + for full in fully_energised: + self._flow_transformer_phases(network_state_operators, full, partial, allow_suspect_flow=True) + await self._run_terminal_trace(partial, trace) + + if self._debug_logger: + self._debug_logger.info("Reprocessing complete.") + + async def _run_terminal_trace(self, terminal: Terminal, network_trace: NetworkTrace[PhasesToFlow]): + await network_trace.run( + terminal, + self.PhasesToFlow( + [NominalPhasePath(SinglePhaseKind.NONE, it) for it in terminal.phases] + ), can_stop_on_start_item=False + ) + + # This is called in a loop so we need to reset it for each call. We choose to do this after to release the memory + # used by the trace once it is finished, rather than before, which has would be marginally quicker on the first + # call, but would hold onto the memory as long as the `SetPhases` instance is referenced. + + network_trace.reset() + + @staticmethod + def _nominal_phase_path_to_phases(nominal_phase_paths: list[NominalPhasePath]) -> list[SinglePhaseKind]: + return [it.to_phase for it in nominal_phase_paths] + + def _create_network_trace( + self, + state_operators: Type[NetworkStateOperators], + partially_energised_transformers: Set[PowerTransformer] + ) -> NetworkTrace[PhasesToFlow]: + + def step_action(nts, ctx): + path, phases_to_flow = nts # We always assume the first step terminal already has the phases applied, so we don't do anything on the first step phases_to_flow.step_flowed_phases = True if ctx.is_start_item else ( - await self._flow_phases(state_operators, path.from_terminal, path.to_terminal, phases_to_flow.nominal_phase_paths) + self._flow_phases(state_operators, path.from_terminal, path.to_terminal, phases_to_flow.nominal_phase_paths) ) - def condition(next_step, nctx, step, ctx): - return len(next_step.data.nominal_phase_paths) > 0 - - def _get_weight(it) -> int: - return it.path.to_terminal.phases.num_phases + # If we flowed phases but failed to completely energise a transformer, keep track of it for reprocessing later. + if (phases_to_flow.step_flowed_phases + and isinstance(path.to_equipment, PowerTransformer) + and _not_fully_energised(state_operators, path.to_terminal) + ): + partially_energised_transformers.add(path.to_equipment) return ( Tracing.network_trace_branching( network_state_operators=state_operators, action_step_type=NetworkTraceActionType.ALL_STEPS, - queue_factory=lambda: WeightedPriorityQueue.process_queue(_get_weight), + debug_logger=self._debug_logger, + name=f'SetPhases({state_operators.description})', + 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=self._compute_next_phases_to_flow(state_operators) ) - .add_queue_condition(condition) + .add_queue_condition( + lambda next_step, x, y, z: len(next_step.data.nominal_phase_paths) > 0 + ) .add_step_action(step_action) ) @@ -171,95 +246,245 @@ def inner(step, _, next_path): return self.PhasesToFlow([]) return self.PhasesToFlow( - self._get_nominal_phase_paths(state_operators, next_path.from_terminal, next_path.to_terminal, self._nominal_phase_path_to_phases(step.data.nominal_phase_paths)) + self._get_nominal_phase_paths( + state_operators, + next_path.from_terminal, + next_path.to_terminal, + self._nominal_phase_path_to_phases(step.data.nominal_phase_paths) + ) ) + return ComputeData(inner) @staticmethod - def _apply_phases(state_operators: Type[NetworkStateOperators], - terminal: Terminal, - phases: List[SinglePhaseKind]): - + def _apply_phases( + phases: List[SinglePhaseKind], + terminal: Terminal, + state_operators: Type[NetworkStateOperators] + ): traced_phases = state_operators.phase_status(terminal) 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: Type[NetworkStateOperators], - from_terminal: Terminal, - to_terminal: Terminal, - phases: Sequence[SinglePhaseKind] - ) -> tuple[NominalPhasePath]: + def _get_nominal_phase_paths( + self, + state_operators: Type[NetworkStateOperators], + from_terminal: Terminal, + to_terminal: Terminal, + phases: Sequence[SinglePhaseKind] = None + ) -> List[NominalPhasePath]: + + if phases is None: + phases = from_terminal.phases.single_phases + traced_internally = from_terminal.conducting_equipment == to_terminal.conducting_equipment phases_to_flow = self._get_phases_to_flow(state_operators, from_terminal, phases, traced_internally) if traced_internally: - return TerminalConnectivityInternal().between(from_terminal, to_terminal, phases_to_flow).nominal_phase_paths + return list(TerminalConnectivityInternal().between(from_terminal, to_terminal, phases_to_flow).nominal_phase_paths) else: - return TerminalConnectivityConnected().terminal_connectivity(from_terminal, to_terminal, phases_to_flow).nominal_phase_paths + return list(TerminalConnectivityConnected().terminal_connectivity(from_terminal, to_terminal, phases_to_flow).nominal_phase_paths) @staticmethod - async def _flow_phases(state_operators: Type[NetworkStateOperators], - from_terminal: Terminal, - to_terminal: Terminal, - nominal_phase_paths: Iterable[NominalPhasePath] - ) -> bool: + def _get_phases_to_flow( + state_operators: Type[NetworkStateOperators], + terminal: Terminal, + phases: Sequence[SinglePhaseKind], + internal_flow: bool + ) -> Set[SinglePhaseKind]: + + if internal_flow: + if ce := terminal.conducting_equipment: + return set(p for p in phases if not state_operators.is_open(ce, p)) + return set() + return set(phases) + + def _flow_phases( + self, + state_operators: Type[NetworkStateOperators], + from_terminal: Terminal, + to_terminal: Terminal, + nominal_phase_paths: List[NominalPhasePath] + ) -> bool: + + if (from_terminal.conducting_equipment == to_terminal.conducting_equipment + and isinstance(from_terminal.conducting_equipment, PowerTransformer) + ): + return self._flow_transformer_phases(state_operators, from_terminal, to_terminal, nominal_phase_paths, allow_suspect_flow=False) + else: + return self._flow_straight_phases(state_operators, from_terminal, to_terminal, nominal_phase_paths) + + def _flow_straight_phases( + self, + state_operators: Type[NetworkStateOperators], + from_terminal: Terminal, + to_terminal: Terminal, + nominal_phase_paths: List[NominalPhasePath] + ) -> bool: from_phases = state_operators.phase_status(from_terminal) to_phases = state_operators.phase_status(to_terminal) - changed_phases = False - - for nominal_phase_path in nominal_phase_paths: - (from_, to) = (nominal_phase_path.from_phase, nominal_phase_path.to_phase) - - try: - def _phase_to_apply(): - # If the path comes from NONE, then we want to apply the `to phase` - if from_ != SinglePhaseKind.NONE: - return from_phases[from_] - elif to not in PhaseCode.XY: - return to - else: - return to_phases[to] - phase = _phase_to_apply() + updated_phases = [] - if phase != SinglePhaseKind.NONE: - to_phases[to] = phase - changed_phases = True + for from_, to_ in ((p.from_phase, p.to_phase) for p in nominal_phase_paths): + self._try_set_phase(from_phases[from_], from_terminal, from_phases, from_, to_terminal, to_phases, to_, lambda: updated_phases.append(True)) - except PhaseException: - phase_desc = f'{from_.name}' if from_ == to else f'path {from_.name} to {to.name}' + return any(updated_phases) - def get_ce_details(terminal: Terminal): - if terminal.conducting_equipment: - return terminal.conducting_equipment - return '' + def _flow_transformer_phases( + self, + state_operators: Type[NetworkStateOperators], + from_terminal: Terminal, + to_terminal: Terminal, + nominal_phase_paths: List[NominalPhasePath] = None, + allow_suspect_flow: bool = False + ) -> bool: - if from_terminal.conducting_equipment and from_terminal.conducting_equipment == to_terminal.conducting_equipment: - terminal_desc = f'from {from_terminal} to {to_terminal} through {from_terminal.conducting_equipment}' - else: - terminal_desc = f'between {from_terminal} on {get_ce_details(from_terminal)} and {to_terminal} on {get_ce_details(to_terminal)}' + paths = nominal_phase_paths or self._get_nominal_phase_paths(state_operators, from_terminal, to_terminal) - raise PhaseException( - f"Attempted to flow conflicting phase {from_phases[from_].name} onto {to_phases[to].name} on nominal phase {phase_desc}. This occurred while " + - f"flowing {terminal_desc}. This is caused by missing open points, or incorrect phases in upstream equipment that should be " + - "corrected in the source data." - ) - return changed_phases + # If this transformer doesn't mess with phases (or only adds or removes a neutral), just use the straight + # processor. We use the number of phases rather than the phases themselves to correctly handle the shift + # from known to unknown phases. e.g. AB -> XY. - @staticmethod - def _get_phases_to_flow( + if from_terminal.phases.without_neutral.num_phases == to_terminal.phases.without_neutral.num_phases: + return self._flow_transformer_phases_adding_neutral(state_operators, from_terminal, to_terminal, paths) + + from_phases = state_operators.phase_status(from_terminal) + to_phases = state_operators.phase_status(to_terminal) + + updated_phases = [] + + # Split the phases into ones we need to flow directly, and ones that have been added by a transformer. In + # the case of an added Y phase (SWER -> LV2 transformer) we need to flow the phases before we can calculate + # the missing phase. + flow_phases = (p for p in paths if p.from_phase == SinglePhaseKind.NONE) + add_phases = (p for p in paths if p.from_phase != SinglePhaseKind.NONE) + for p in flow_phases: + self._try_add_phase(from_terminal, from_phases, to_terminal, to_phases, p.to_phase, allow_suspect_flow, + lambda: updated_phases.append(True)) + + for p in add_phases: + self._try_set_phase(from_phases[p.from_phase], from_terminal, from_phases, p.from_phase, + to_terminal, to_phases, p.to_phase, lambda: updated_phases.append(True)) + + return any(updated_phases) + + def _flow_transformer_phases_adding_neutral( + self, state_operators: Type[NetworkStateOperators], - terminal: Terminal, - phases: Sequence[SinglePhaseKind], - internal_flow: bool - ) -> Set[SinglePhaseKind]: + from_terminal: Terminal, + to_terminal: Terminal, + paths: List[NominalPhasePath] + ) -> bool: + + updated_phases = self._flow_straight_phases(state_operators, from_terminal, to_terminal, + [it for it in paths if it != add_neutral]) + + # Only add the neutral if we added a phases to the transformer, otherwise you will flag an energised neutral + # with no active phases. We check to see if we need to add the neutral to prevent adding it when we traverse + # through the transformer in the opposite direction. + + if updated_phases and (add_neutral in paths): + state_operators.phase_status(to_terminal)[SinglePhaseKind.N] = SinglePhaseKind.N - equip = terminal.conducting_equipment - if equip and internal_flow: - return {phase for phase in terminal.phases.single_phases if not state_operators.is_open(equip, phase)} - return set(phases) + return updated_phases + + def _try_set_phase( + self, + phase: SinglePhaseKind, + from_terminal: Terminal, + from_phases: PhaseStatus, + from_: SinglePhaseKind, + to_terminal: Terminal, + to_phases: PhaseStatus, + to_: SinglePhaseKind, + on_success: Callable[[], None] + ): + try: + if phase != SinglePhaseKind.NONE and to_phases.__setitem__(to_, phase): + if self._debug_logger: + self._debug_logger.info(f' {from_terminal.mrid}[{from_}] -> {to_terminal.mrid}[{to_}]: set to {phase}') + on_success() + except PhaseException: + self._throw_cross_phase_exception(from_terminal, from_phases, from_, to_terminal, to_phases, to_) + + def _try_add_phase( + self, + from_terminal: Terminal, + from_phases: PhaseStatus, + to_terminal: Terminal, + to_phases: PhaseStatus, + to_: SinglePhaseKind, + allow_suspect_flow: bool, + on_success: Callable[[], None] + ): + # The phases that can be added are ABCN and Y, so for all cases other than Y we can just use the added phase. For + # Y we need to look at what the phases on the other side of the transformer are to determine what has been added. + + phase = _unless_none( + to_phases[to_], _to_y_phase(from_phases[from_terminal.phases.single_phases[0]], allow_suspect_flow) + ) if to_ == SinglePhaseKind.Y else to_ + + self._try_set_phase(phase, from_terminal, from_phases, SinglePhaseKind.NONE, to_terminal, to_phases, to_, on_success) @staticmethod - def _nominal_phase_path_to_phases(nominal_phase_paths: list[NominalPhasePath]) -> list[SinglePhaseKind]: - return list(map((lambda it: it.to_phase), nominal_phase_paths)) + def _throw_cross_phase_exception( + from_terminal: Terminal, + from_phases: PhaseStatus, + from_: SinglePhaseKind, + to_terminal: Terminal, + to_phases: PhaseStatus, + to_: SinglePhaseKind + ): + phase_desc = f'{from_.name}' if from_ == to_ else f'path {from_.name} to {to_.name}' + + def get_ce_details(terminal: Terminal): + if terminal.conducting_equipment: + return terminal.conducting_equipment + return '' + + if from_terminal.conducting_equipment == to_terminal.conducting_equipment: + terminal_desc = f'from {from_terminal} to {to_terminal} through {from_terminal.conducting_equipment}' + else: + terminal_desc = f'between {from_terminal} on {get_ce_details(from_terminal)} and {to_terminal} on {get_ce_details(to_terminal)}' + + raise PhaseException( + f"Attempted to flow conflicting phase {from_phases[from_].name} onto {to_phases[to_].name} on nominal phase {phase_desc}. This occurred while " + + f"flowing {terminal_desc}. This is often caused by missing open points, or incorrect phases in upstream equipment that should be " + + "corrected in the source data." + ) + + +def _not_fully_energised(network_state_operators: Type[NetworkStateOperators], terminal: Terminal) -> bool: + phase_status = network_state_operators.phase_status(terminal) + return any(phase_status[it] == SinglePhaseKind.NONE for it in terminal.phases.single_phases) + + +def _unless_none(single_phase_kind: SinglePhaseKind, default: SinglePhaseKind) -> Optional[SinglePhaseKind]: + if single_phase_kind == SinglePhaseKind.NONE: + return default + return single_phase_kind + + +def _to_y_phase(phase: SinglePhaseKind, allow_suspect_flow: bool) -> SinglePhaseKind: + # NOTE: If we are adding Y to a C <-> XYN transformer we will leave it de-energised to prevent cross-phase energisation + # when there is a parallel C to XN transformer. This can be changed if the entire way XY mappings are reworked to + # use traced phases instead of the X and Y, which includes in straight paths to prevent cross-phase wiring. + # + # Due to both AB and AC energising X with A, until the above is fixed we don't know which one we are using, so if + # we aren't allowing suspect flows we will also leave it de-energised to prevent cross-phase energisation when you + # have parallel XY <-> XN transformers on an AC line (adds B to the Y "C wire"). If we are allowing suspect flows + # for partially energised transformers on a second pass we will default these to use AB. + + if phase == SinglePhaseKind.A: + if allow_suspect_flow: + return SinglePhaseKind.B + else: + return SinglePhaseKind.NONE + elif phase == SinglePhaseKind.B: + return SinglePhaseKind.C + elif phase == SinglePhaseKind.C: + return SinglePhaseKind.NONE + else: + return SinglePhaseKind.NONE 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 514e4cd36..82e58290a 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 @@ -11,17 +11,17 @@ T = TypeVar('T') U = TypeVar('U') -__all__ = ['ContextValueComputer', 'TypedContextValueComputer'] +__all__ = ['ContextValueComputer'] 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?`. + Interface representing a context value computer used to compute and store values in a `StepContext`. Implementations compute initial and subsequent context values during traversal steps. `T` The type of items being traversed. """ + def __init__(self, key: str): self.key = key # A unique key identifying the context value computed by this computer. @@ -30,63 +30,32 @@ def compute_initial_value(self, item: T): """ Computes the initial context value for the given starting item. - `item` The starting item for which to compute the initial context value. - Returns The initial context value associated with the starting item. + :param item: The starting item for which to compute the initial context value. + :return: The initial context value associated with the starting item. """ - pass + raise NotImplemented @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. - `nextItem` The next item in the traversal. - `currentItem` The current item of the traversal. - `currentValue` The current context value associated with the current item. - Returns The updated context value for the next item. - """ - pass - - def is_standalone_computer(self): - return not isinstance(self, (StepAction, StopCondition, QueueCondition)) - -class TypedContextValueComputer(ContextValueComputer, Generic[T, U]): - """ - A typed version of [ContextValueComputer] that avoids unchecked casts by specifying the type of context value. - This interface allows for type-safe computation of context values in implementations. - - `T` The type of items being traversed. - `U` The type of the context value computed and stored. - """ - def compute_initial_value(self, item: T): - """ - Computes the initial context value of type [U] for the given starting item. - - `item` The starting item for which to compute the initial context value. - Returns The initial context value associated with the starting item. - """ - pass - - def compute_next_value(self, next_item: T, current_item: T, current_value) -> U: - return self.compute_next_value_typed(next_item, current_item, current_value) - - def compute_next_value_typed(self, next_item: T, current_item: T, current_value) -> U: + :param next_item: The next item in the traversal. + :param current_item: The current item of the traversal. + :param current_value: The current context value associated with the current item. + :return: The updated context value for the next item. """ - Computes the next context value of type [U] based on the current item, next item, and the current context value. + raise NotImplemented - `nextItem` The next item in the traversal. - `currentItem` The current item being processed. - `currentValue` The current context value associated with the current item cast to type [U]. - Returns The updated context value of type for the next item. - """ - pass - def get_context_value(self, context: StepContext): """ Gets the computed value from the context cast to type [U]. """ return context.get_value(self.key) + def is_standalone_computer(self): + return not isinstance(self, (StepAction, StopCondition, QueueCondition)) + # these imports are here to stop circular imports from zepben.evolve.services.network.tracing.traversal.stop_condition import StopCondition diff --git a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py new file mode 100644 index 000000000..185c2e045 --- /dev/null +++ b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py @@ -0,0 +1,123 @@ +# 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/. +__all__ = ['DebugLoggingWrapper'] + +import copy +import functools +from logging import Logger +from types import FunctionType +from typing import TypeVar, Union, Type, List, Tuple, Dict + +from zepben.evolve.services.network.tracing.traversal.queue_condition import QueueCondition +from zepben.evolve.services.network.tracing.traversal.step_action import StepAction +from zepben.evolve.services.network.tracing.traversal.stop_condition import StopCondition + +T = TypeVar('T') + +Wrappable = Union[StepAction[T], QueueCondition[T], StopCondition[T]] + +DebugLoggingDataParam = List[Tuple[Union[Tuple[str, str], str], str]] + +# class_to_wrap: +# - attr_name: 'attr_name=(log_msg)' +# - (attr_name, log_name): 'log_name=(log_msg)' +_data: Dict[Type[Wrappable], DebugLoggingDataParam] = { + StepAction: [(('apply', 'stepped_on'), ' [item={args[0]}, context={args[1]}]')], + StopCondition: [('should_stop', '={result} [item={args[0]}, context={args[1]}]')], + QueueCondition: [ + ('should_queue', '={result} [next_item={args[0]}, next_context={args[1]}, current_item={args[2]}, current_context={args[3]}]'), + ('should_queue_start_item', '={result} [item={args[0]}]'), + ], +} + + +class DebugLoggingWrapper: + + def __init__(self, description: str, logger: Logger): + self.description: str = description + self._logger: Logger = logger + self._wrapped = { + StepAction: 0, + StopCondition: 0, + QueueCondition: 0 + } + + def wrap(self, obj: Wrappable): + """ + Return a new object with debug logging wrappers applied to supported methods of the object. + + Supported methods by object class:: + + - :meth:`StepAction.action` + - :meth:`StopCondition.should_stop` + - :class:`QueueCondition` + - :meth:`should_queue` + - :meth:`should_queue_start_item` + + :param obj: Instantiated object representing a condition or action in a :class:`zepben.evolve.Traversal`. + :return: new copy of the object passed in for fluent use. + + :raises AttributeError: If wrapping the passed in object type is not supported. + """ + + # Create a shallow copy of the object as early as possible to avoid accidentally modifying the original. + w_obj = copy.copy(obj) + + def _get_logger_index(_clazz: Type[Wrappable]) -> int: + """ + This is just a very lazy way of auto counting the number of objects wrapped + based on their basic classification without requiring any information in the + object aside from what it inherits from. + + """ + + self._wrapped[clazz] += 1 + return self._wrapped[clazz] + + def _wrap_attr(_index: int, _attr: str, _msg: str) -> None: + """ + Replaces the specified attr with a wrapper around the same attr to inject + logging. + + :param _attr: Method/Function name. + :raises AttributeError: if ``wrappable`` is already wrapped + """ + + if isinstance(_attr, tuple): + _attr_name, _log_attr_name = _attr + else: + _attr_name = _log_attr_name = _attr + + to_wrap = getattr(w_obj, _attr_name) + + setattr(w_obj, _attr_name, self._log_method_call(to_wrap, f'{self.description}: {_log_attr_name}({_index})' + _msg)) + + for clazz in (StepAction, StopCondition, QueueCondition): + if isinstance(w_obj, clazz): + index = _get_logger_index(clazz) + for attr, msg in _data.get(clazz): + _wrap_attr(index, attr, msg) + return w_obj + else: + raise NotImplementedError(f'{type(self).__name__} does not support wrapping {obj}') + + def _log_method_call(self, func: FunctionType, log_string: str): + """ + returns ``func`` wrapped with call to ``self._logger`` using ``log_string`` as the format + + :param func: any callable + :param log_string: Log message format string to output when ``attr`` is called, args/kwargs + passed to the function are passed to :code:`str.format()`, as well as is ``result`` which is the + result of the function itself + """ + + @functools.wraps(func) + def wrapper(*args, **kwargs): + result = func(*args, **kwargs) + msg = f"{self._logger.name}: {log_string.format(result=result, args=args, kwargs=kwargs)}" + self._logger.debug(msg) + return result + + return wrapper 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 d60b4723d..9525a1d6e 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/queue_condition.py +++ b/src/zepben/evolve/services/network/tracing/traversal/queue_condition.py @@ -31,33 +31,44 @@ def __init__(self, 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. + Determines whether the `next_item` should be queued for traversal. - `nextItem` The next item to be potentially queued. - `nextContext` The context associated with the [nextItem]. - `currentItem` The current item being processed in the traversal. - `currentContext` The context associated with the [currentItem]. - Returns `true` if the [nextItem] should be queued; `false` otherwise. + `next_item` The next item to be potentially queued. + `next_context` The context associated with the `next_iItem`. + `current_item` The current item being processed in the traversal. + `current_context` The context associated with the `current_item`. + Returns `True` if the `next_tem` should be queued; `False` otherwise. """ - raise NotImplementedError + + raise NotImplemented @staticmethod def should_queue_start_item(item: T) -> bool: """ - Determines whether a traversal startItem should be queued when running a [Traversal]. + Determines whether a traversal start_item 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`. + :param item: The item to be potentially queued. + :eturn: `True` if the `item` should be queued; `False` otherwise. Defaults to `True`. """ + return True -from zepben.evolve.services.network.tracing.traversal.context_value_computer import TypedContextValueComputer +from zepben.evolve.services.network.tracing.traversal.context_value_computer import ContextValueComputer + -class QueueConditionWithContextValue(QueueCondition[T], TypedContextValueComputer[T, U], Generic[T, U]): +class QueueConditionWithContextValue(QueueCondition[T], ContextValueComputer[T], Generic[T, U]): """ - Interface representing a queue condition that requires a value stored in the [StepContext] to determine if an item should be queued. + Interface representing a queue 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. """ + + @abstractmethod + def compute_initial_value(self, item: T): + raise NotImplemented + + @abstractmethod + def compute_next_value(self, next_item: T, current_item: T, current_value): + raise NotImplemented 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 6de17e3d4..fa24beeda 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/step_action.py +++ b/src/zepben/evolve/services/network/tracing/traversal/step_action.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 abc import abstractmethod +from typing import TypeVar, Generic, Callable, final -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.context_value_computer import ContextValueComputer from zepben.evolve.services.network.tracing.traversal.step_context import StepContext T = TypeVar('T') @@ -23,23 +23,60 @@ class StepAction(Generic[T]): `T` The type of items being traversed. """ - def __init__(self, _func: StepActionFunc): - self._func = _func + def __init__(self, _func: StepActionFunc = None): + self._func = _func or self._apply + + def __init_subclass__(cls): + """ + Due to ``apply`` needing to call ``self._func`` to allow the method wrapping used in + ``Traversal.if_stopping()`` and ``Traversal.if_not_stopping()`` we **DO NOT** allow this + method to be overridden directly. + + :raises Exception: If ``cls.apply`` is overridden + """ + if 'apply' in cls.__dict__.keys(): + raise Exception(f"method 'apply' should not be directly overridden, override '_apply' instead.") + super().__init_subclass__() + + @final def apply(self, item: T, context: StepContext): """ - Applies the action to the specified [item]. + Applies the action to the specified ``item``. - `item` The current item in the traversal. - `context` The context associated with the current traversal step. + :param item: The current item in the traversal. + :param context: The context associated with the current traversal step. """ + return self._func(item, context) -class StepActionWithContextValue(StepAction[T], TypedContextValueComputer[T, U]): + @abstractmethod + def _apply(self, item: T, context: StepContext): + """ + Override this method instead of ``self.apply`` directly + + :param item: The current item in the traversal. + :param context: The context associated with the current traversal step. + """ + raise NotImplementedError() + + +class StepActionWithContextValue(StepAction[T], ContextValueComputer[T]): """ - Interface representing a step action that utilises a value stored in the [StepContext]. + Interface representing a step action that utilises a value stored in the :class:`StepContext`. `T` The type of items being traversed. `U` The type of the context value computed and used in the action. """ - pass + + def __init__(self, key: str, _func: StepActionFunc = None): + StepAction.__init__(self, _func) + ContextValueComputer.__init__(self, key) + + @abstractmethod + def compute_initial_value(self, item: T): + raise NotImplementedError() + + @abstractmethod + def compute_next_value(self, next_item: T, current_item: T, current_value): + raise NotImplementedError() 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 65e72b3d5..76e7f0502 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/step_context.py +++ b/src/zepben/evolve/services/network/tracing/traversal/step_context.py @@ -13,17 +13,18 @@ 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. This context is passed to conditions and actions during a traversal to provide additional information about each step. - Any [ContextValueComputer] registered with the traversal will put the computed value into this context with the given [ContextValueComputer.key] which can - be retrieved by using [getValue]. - - `isStartItem` Indicates whether the current item is a starting item of the traversal. - `isBranchStartItem` Indicates whether the current item is the start of a new branch in a branching traversal. - `stepNumber` The number of steps taken in the traversal so far for this traversal path. - `branchDepth` The depth of the current branch in a branching traversal. - `isStopping` Indicates whether the traversal is stopping at the current item due to a stop condition. + + Any `ContextValueComputer` registered with the traversal will put the computed value into this context with the given `ContextValueComputer.key` which can + be retrieved by using `get_value`. + + :var is_start_item: Indicates whether the current item is a starting item of the traversal. + :var is_branch_start_item: Indicates whether the current item is the start of a new branch in a branching traversal. + :var step_number: The number of steps taken in the traversal so far for this traversal path. + :var branch_depth: The depth of the current branch in a branching traversal. + :var is_stopping: Indicates whether the traversal is stopping at the current item due to a stop condition. """ - def __init__(self, is_start_item: bool, is_branch_start_item: bool, step_number: int=0, branch_depth: int=0, values: dict=None): + def __init__(self, is_start_item: bool, is_branch_start_item: bool, step_number: int = 0, branch_depth: int = 0, values: dict = None): self.is_start_item = is_start_item self.is_branch_start_item = is_branch_start_item self.step_number = step_number @@ -40,6 +41,7 @@ def set_value(self, key: str, value): `key` The key identifying the context value. `value` The value to associate with the key. """ + self._values[key] = value def get_value(self, key: str) -> T: @@ -49,4 +51,8 @@ def get_value(self, key: str) -> T: `key` The key identifying the context value. @return The context value associated with the key, or `None` if not found. """ - return self._values.get(key) \ No newline at end of file + + return self._values.get(key) + + def __str__(self) -> str: + return f"StepContext({', '.join('{}={}'.format(*i) for i in vars(self).items())})" 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 f63a0f2fb..532e0f874 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/stop_condition.py +++ b/src/zepben/evolve/services/network/tracing/traversal/stop_condition.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 abc import abstractmethod 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.context_value_computer import ContextValueComputer from zepben.evolve.services.network.tracing.traversal.step_context import StepContext from zepben.evolve.services.network.tracing.traversal.traversal_condition import TraversalCondition @@ -21,9 +21,10 @@ 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): + + def __init__(self, stop_function: ShouldStop = None): if stop_function is not None: self.should_stop = stop_function @@ -38,10 +39,18 @@ def should_stop(self, item: T, context: StepContext) -> bool: """ -class StopConditionWithContextValue(StopCondition[T], TypedContextValueComputer[T, U]): +class StopConditionWithContextValue(StopCondition[T], ContextValueComputer[T]): """ 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. """ + + @abstractmethod + def compute_initial_value(self, item: T): + raise NotImplemented + + @abstractmethod + def compute_next_value(self, next_item: T, current_item: T, current_value): + raise NotImplemented diff --git a/src/zepben/evolve/services/network/tracing/traversal/traversal.py b/src/zepben/evolve/services/network/tracing/traversal/traversal.py index f65e8defe..43b5c968a 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/traversal.py +++ b/src/zepben/evolve/services/network/tracing/traversal/traversal.py @@ -5,14 +5,17 @@ from __future__ import annotations +import inspect from abc import abstractmethod from collections import deque from collections.abc import Callable from functools import singledispatchmethod +from logging import Logger 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.debug_logging import DebugLoggingWrapper 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 @@ -25,103 +28,126 @@ T = TypeVar('T') U = TypeVar('U') D = TypeVar('D', bound='Traversal') +QT = TypeVar('QT') +QD = TypeVar('QD') QueueConditionTypes = Union[ShouldQueue, QueueCondition[T]] StopConditionTypes = Union[ShouldStop, StopCondition[T]] ConditionTypes = Union[QueueConditionTypes, StopConditionTypes] +StepActionTypes = Union[StepActionFunc, StepAction] class Traversal(Generic[T, D]): """ A base traversal class allowing items in a connected graph to be traced. It provides the main interface and implementation for traversal logic. - This class manages conditions, actions, and context values that guide each traversal step. + This class manages conditions, actions, and context values that guide each + traversal step. - This class supports a concept of 'branching', whereby when a new branch is created a new child traversal instance is created. The child - inherits its parents conditions, actions and what it has tracked. However, it knows nothing about what its siblings have tracked. This - allows traversing both ways around loops in the graph. + This class supports a concept of 'branching', whereby when a new branch is + created a new child traversal instance is created. The child inherits its + parents conditions, actions and what it has tracked. However, it knows nothing + about what its siblings have tracked. This allows traversing both ways around + loops in the graph. - This class is abstract to allow for type-specific implementations for branching traversals and custom start item handling. + This class is abstract to allow for type-specific implementations for branching + traversals and custom start item handling. This class is **not thread safe**. `T` The type of object to be traversed. - `D` The specific type of traversal, extending [Traversal]. + `D` The specific type of traversal, extending :class:`Traversal`. + + :var name: The name of the traversal. Can be used for logging purposes and will be included in all debug logging. + :var _queue_type: The type of queue to use for processing this traversal. + :var _parent: The parent traversal, or None if this is a root level traversal. Primarily used to track branching traversals. + :var _debug_logger: An optional logger to add information about how the trace is processing items. """ - class QueueType(Generic[T, D]): + class QueueType(Generic[QT, QD]): """ Defines the types of queues used in the traversal. + + :var queue_next: Logic for queueing the next item in the traversal. + :var queue: The primary queue of items. """ - queue_next: Traversal.QueueNext[T] + + queue_next: Traversal.QueueNext[QT] + queue: TraversalQueue[QT] @property - def queue(self) -> TraversalQueue[T]: + @abstractmethod + def queue(self) -> TraversalQueue[QT]: raise NotImplementedError @property - def branch_queue(self) -> Optional[TraversalQueue[D]]: + def branch_queue(self) -> Optional[TraversalQueue[QD]]: raise NotImplementedError - - class BasicQueueType(QueueType[T, D], Generic[T, D]): + class BasicQueueType(QueueType[QT, QD]): """ Basic queue type that handles non-branching item queuing. - `queueNext` Logic for queueing the next item in the traversal. - `queue` The primary queue of items. + :param queue_next: Logic for queueing the next item in the traversal. + :param queue: The primary queue of items. """ - def __init__(self, queue_next: Traversal.QueueNext[T], queue: TraversalQueue[T]): + + def __init__(self, queue_next: Traversal.QueueNext[QT], queue: TraversalQueue[QT]): self.queue_next = queue_next self._queue = queue self._branch_queue = None @property - def queue(self) -> TraversalQueue[T]: + def queue(self) -> TraversalQueue[QT]: + """The primary queue of items.""" return self._queue @property - def branch_queue(self) -> Optional[TraversalQueue[D]]: + def branch_queue(self) -> Optional[TraversalQueue[QD]]: return self._branch_queue - - class BranchingQueueType(QueueType[T, D], Generic[T, D]): + class BranchingQueueType(QueueType[QT, QD]): """ - Branching queue type, supporting operations that may split into separate branches during traversal. + Branching queue type, supporting operations that may split into separate + branches during traversal. - `queueNext` Logic for queueing the next item in a branching traversal. - `queueFactory` Factory function to create the main queue. - `branchQueueFactory` Factory function to create the branch queue. + :param queue_next: Logic for queueing the next item in a branching traversal. + :param queue_factory: Factory function to create the main queue. + :param branch_queue_factory: Factory function to create the branch queue. """ - def __init__(self, - queue_next: Traversal.BranchingQueueNext[T], - queue_factory: Callable[[], TraversalQueue[T]], - branch_queue_factory: Callable[[], TraversalQueue[D]]): - self.queue_next: Traversal.BranchingQueueNext[T] = queue_next + + def __init__( + self, + queue_next: Traversal.BranchingQueueNext[QT], + queue_factory: Callable[[], TraversalQueue[QT]], + branch_queue_factory: Callable[[], TraversalQueue[QD]], + ): + self.queue_next: Traversal.BranchingQueueNext[QT] = queue_next self.queue_factory = queue_factory self.branch_queue_factory = branch_queue_factory @property - def queue(self) -> TraversalQueue[T]: + def queue(self) -> TraversalQueue[QT]: return self.queue_factory() @property - def branch_queue(self) -> Optional[TraversalQueue[D]]: + def branch_queue(self) -> Optional[TraversalQueue[QD]]: return self.branch_queue_factory() - _queue_type: Union[BasicQueueType, BranchingQueueType] + name: str - def __init__(self, queue_type, parent: Optional[D] = None): + def __init__(self, queue_type, parent: Optional[D] = None, debug_logger: Logger = None): self._queue_type = queue_type self._parent: D = parent + self._debug_logger = DebugLoggingWrapper(self.name, debug_logger) if debug_logger else None - if type(self._queue_type) == Traversal.BasicQueueType: + if type(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) + elif type(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] = self._queue_type.queue - self.branch_queue: Optional[TraversalQueue[D]] = self._queue_type.branch_queue + self.queue: TraversalQueue[T] = queue_type.queue + self.branch_queue: Optional[TraversalQueue[D]] = queue_type.branch_queue self.start_items: deque[T] = deque() self.running: bool = False @@ -148,12 +174,14 @@ def parent(self, value): 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. + Determines if the traversal can apply step actions and stop conditions + on the specified item. - `item` The item to check. - `context` The context of the current traversal step. - Returns `true` if the item can be acted upon; `false` otherwise. + :param item: The item to check. + :param context: The context of the current traversal step. + :returns: ``True`` if the item can be acted upon; ``False`` otherwise. """ + return True def can_visit_item(self, item: T, context: StepContext) -> bool: @@ -163,8 +191,14 @@ def create_new_this(self) -> D: """ Creates a new instance of the traversal for branching purposes. - Returns A new traversal instance. + NOTE: Do NOT add the debug logger to this call, as all traces created for + branching will already have their actions wrapped, and passing the + debug logger through means you get duplicate wrappers that double, + triple etc. log the debug messages. + + :returns: A new traversal instance. """ + raise NotImplementedError @singledispatchmethod @@ -176,28 +210,33 @@ def add_condition(self, condition: ConditionTypes) -> D: :return: this traversal instance. """ - if callable(condition): # Callable[[NetworkTraceStep[T], StepContext], None] - if condition.__code__.co_argcount == 2: + + if callable(condition): # Callable[[NetworkTraceStep[T], StepContext], None] + if len(inspect.getfullargspec(condition).args) == 2: return self.add_stop_condition(condition) - elif condition.__code__.co_argcount == 4: + elif len(inspect.getfullargspec(condition).args) == 4: return self.add_queue_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 [{condition.__class__.__name__}] does not match expected: ' + - "[QueueCondition | DirectionCondition | StopCondition | Callable[_,_] | Callable[_,_,_,_]]") + raise RuntimeError( + f'Condition [{condition.__class__.__name__}] does not match expected: ' + + "[QueueCondition | DirectionCondition | StopCondition | Callable[_,_] | Callable[_,_,_,_]]" + ) @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. + 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. - `condition` The stop condition to add. - Returns this traversal instance. + :param condition: The stop condition to add. + :return: this traversal instance. """ + raise RuntimeError(f'Condition [{condition.__class__.__name__}] does not match expected: [StopCondition | StopConditionWithContextValue | Callable]') @add_stop_condition.register(Callable) @@ -206,6 +245,10 @@ def _(self, condition: ShouldStop): @add_stop_condition.register def _(self, condition: StopCondition): + + if self._debug_logger is not None: + self._debug_logger.wrap(condition) + self.stop_conditions.append(condition) if isinstance(condition, StopConditionWithContextValue): self.compute_next_context_funs[condition.key] = condition @@ -215,9 +258,10 @@ def copy_stop_conditions(self, other: Traversal[T, D]) -> D: """ Copies all the stop conditions from another traversal to this traversal. - `other` The other traversal object to copy from. - Returns The current traversal instance. + :param other: The other traversal object to copy from. + :return: The current traversal instance. """ + for it in other.stop_conditions: self.add_stop_condition(it) return self @@ -232,12 +276,14 @@ def matches_any_stop_condition(self, item: T, context: StepContext) -> bool: @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. + 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. :param condition: The queue condition to add. :returns: The current traversal instance. """ + raise RuntimeError(f'Condition [{condition.__class__.__name__}] does not match expected: [QueueCondition | QueueConditionWithContextValue | Callable]') @add_queue_condition.register(Callable) @@ -246,6 +292,10 @@ def _(self, condition: ShouldQueue): @add_queue_condition.register def _(self, condition: QueueCondition): + + if self._debug_logger is not None: + self._debug_logger.wrap(condition) + self.queue_conditions.append(condition) if isinstance(condition, QueueConditionWithContextValue): self.compute_next_context_funs[condition.key] = condition @@ -258,56 +308,83 @@ def copy_queue_conditions(self, other: Traversal[T, D]) -> D: :param other: The other traversal from which to copy queue conditions. :returns: The current traversal instance. """ + for it in other.queue_conditions: self.add_queue_condition(it) return self - def add_step_action(self, action: Union[StepActionFunc, StepAction[T]]) -> D: + @singledispatchmethod + def add_step_action(self, action: StepActionTypes) -> D: """ - Adds an action to be performed on each item in the traversal, including the starting items. + 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. + :param action: The action to perform on each item. + :return: The current traversal instance. """ - if isinstance(action, StepAction): - self.step_actions.append(action) - if isinstance(action, StepActionWithContextValue): - self.compute_next_context_funs[action.key] = action - return self - elif callable(action): - return self.add_step_action(StepAction(action)) + raise RuntimeError(f'StepAction [{action.__class__.__name__}] does not match expected: [StepAction | StepActionWithContextValue | Callable]') - raise RuntimeError(f'Condition [{action.__class__.__name__}] does not match expected: [StepAction | StepActionWithContextValue | Callable]') + @add_step_action.register + def _(self, action: StepAction): + if self._debug_logger is not None: + self._debug_logger.wrap(action) - def if_not_stopping(self, action: Callable[[T, StepContext], None]) -> D: + self.step_actions.append(action) + if isinstance(action, StepActionWithContextValue): + self.compute_next_context_funs[action.key] = action + return self + + @add_step_action.register(Callable) + def _(self, action: StepActionFunc): + return self.add_step_action(StepAction(action)) + + @singledispatchmethod + def if_not_stopping(self, action: StepActionTypes) -> D: """ Adds an action to be performed on each item that does not match any stop condition. - `action` The action to perform on each non-stopping item. - Returns The current traversal instance. + :param action: The action to perform on each non-stopping item. + :return: The current traversal instance. """ - self.step_actions.append(StepAction(lambda it, context: action(it, context) if not context.is_stopping else None)) - return self + raise RuntimeError(f'StepAction [{action}] does not match expected: [StepAction | StepActionWithContextValue | Callable]') + @if_not_stopping.register(Callable) + def _(self, action: StepActionFunc) -> D: + return self.add_step_action(lambda it, context: action(it, context) if not context.is_stopping else None) - def if_stopping(self, action: Callable[[T, StepContext], None]) -> D: + @if_not_stopping.register + def _(self, action: StepAction) -> D: + action.apply = lambda it, context: action._func(it, context) if not context.is_stopping else None + return self.add_step_action(action) + + @singledispatchmethod + def if_stopping(self, action: StepActionTypes) -> D: """ Adds an action to be performed on each item that matches a stop condition. - `action` The action to perform on each stopping item. - Returns The current traversal instance. + :param action: The action to perform on each stopping item. + :return: The current traversal instance. """ - self.step_actions.append(StepAction(lambda it, context: action(it, context) if context.is_stopping else None)) - return self + raise RuntimeError(f'StepAction [{action}] does not match expected: [StepAction | StepActionWithContextValue | Callable]') + + @if_stopping.register(Callable) + def _(self, action: StepActionFunc) -> D: + return self.add_step_action(lambda it, context: action(it, context) if context.is_stopping else None) + + @if_stopping.register + def _(self, action: StepAction) -> D: + action.apply = lambda it, context: action._func(it, context) if context.is_stopping else None + return self.add_step_action(action) def copy_step_actions(self, other: Traversal[T, D]) -> D: """ Copies all the step actions from the passed in traversal to this traversal. - `other` The other traversal object to copy from. - Returns The current traversal instance. + :param other: The other traversal object to copy from. + :return: The current traversal instance. """ + for it in other.step_actions: self.add_step_action(it) return self @@ -322,23 +399,28 @@ async def apply_step_actions(self, item: T, context: StepContext) -> D: def add_context_value_computer(self, computer: ContextValueComputer[T]) -> D: """ - Adds a standalone context value computer to compute additional [StepContext] values during traversal. + Adds a standalone context value computer to compute additional `StepContext` + values during traversal. - `computer` The context value computer to add. - Returns The current traversal instance. + :param computer: The context value computer to add. + :return: The current traversal instance. """ - #require(not issubclass(computer.__class__, 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 def copy_context_value_computer(self, other: Traversal[T, D]) -> D: """ - Copies all standalone context value computers from another traversal to this traversal. - That is, it does not copy any [TraversalCondition] registered that also implements [ContextValueComputer] + Copies all standalone context value computers from another traversal to this + traversal. + That is, it does not copy any `TraversalCondition` registered that also + implements `ContextValueComputer` - `other` The other traversal from which to copy context value computers. - Returns The current traversal instance. + :param other: The other traversal from which to copy context value computers. + :return: The current traversal instance. """ + for it in other.compute_next_context_funs.values(): if it.is_standalone_computer(): self.add_context_value_computer(it) @@ -355,28 +437,30 @@ def _compute_next_context(self, current_item: T, context: StepContext, next_step for key, computer in self.compute_next_context_funs.items(): new_context_data[key] = computer.compute_next_value(next_step, current_item, context.get_value(key)) - branch_depth = context.branch_depth +1 if is_branch_start else context.branch_depth + branch_depth = context.branch_depth + 1 if is_branch_start else context.branch_depth return StepContext(False, is_branch_start, context.step_number + 1, branch_depth, new_context_data) def add_start_item(self, item: T) -> D: """ Adds a starting item to the traversal. - `item` The item to add. - Returns The current traversal instance. + :param item: The item to add. + :return: The current traversal instance. """ + self.start_items.append(item) return self - - async def run(self, start_item: T=None, can_stop_on_start_item: bool=True) -> D: + async def run(self, start_item: T = None, can_stop_on_start_item: bool = True) -> D: """ Runs the traversal optionally adding [startItem] to the collection of start items. - `startItem` The item from which to start the traversal. (optional) - `canStopOnStartItem` Indicates if the traversal should check stop conditions on the starting item. - Returns The current traversal instance. + :param start_item: The item from which to start the traversal. (optional) + :param can_stop_on_start_item: Indicates if the traversal should check stop conditions + on the starting item. + :return: The current traversal instance. """ + if start_item is not None: self.start_items.append(start_item) @@ -405,8 +489,9 @@ def reset(self) -> D: """ Resets the traversal to allow it to be reused. - Returns The current traversal instance. + :return: The current traversal instance. """ + require(not self.running, lambda: "Traversal is currently running.") self.has_run = False self.queue.clear() @@ -420,8 +505,10 @@ def reset(self) -> D: @abstractmethod def on_reset(self): """ - Called when the traversal is reset. Derived classes can override this to reset additional state. + Called when the traversal is reset. Derived classes can override this to + reset additional state. """ + raise NotImplementedError() def _branch_start_items(self): @@ -464,7 +551,6 @@ async def _traverse(self, can_stop_on_start_item: bool): if not context.is_stopping: self.queue_next(current, context) - def _get_step_context(self, item: T) -> StepContext: try: context = self.contexts.pop(item) @@ -473,6 +559,7 @@ 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: + # fmt: off it = ( self.create_new_this() .copy_queue_conditions(self) @@ -480,6 +567,7 @@ def _create_new_branch(self, start_item: T, context: StepContext) -> D: .copy_stop_conditions(self) .copy_context_value_computer(self) ) + # fmt: on it.contexts[start_item] = context Traversal.add_start_item(it, start_item) @@ -532,7 +620,6 @@ def _can_queue_start_item(self, start_item: T) -> bool: return False return True - class QueueNext(Generic[T]): def __init__(self, func): self._func = func diff --git a/src/zepben/evolve/testing/test_network_builder.py b/src/zepben/evolve/testing/test_network_builder.py index 7c327c373..508c877b0 100644 --- a/src/zepben/evolve/testing/test_network_builder.py +++ b/src/zepben/evolve/testing/test_network_builder.py @@ -2,15 +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/. +from logging import Logger +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, BusbarSection, Clamp, Cut, Site) from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing -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, BusbarSection, Clamp, Cut, Site - SubclassesConductingEquipment = TypeVar('SubclassesConductingEquipment', bound=ConductingEquipment) @@ -367,7 +366,7 @@ def to_busbar_section( mrid: str = None, connectivity_node_mrid: Optional[str] = None, action: Callable[[BusbarSection], None] = null_action - ) -> 'TestNetworkBuilder': + ) -> 'TestNetworkBuilder': """ Add a new `BusbarSection` to the network and connect it to the current network pointer, updating the network pointer to the new `BusbarSection`. @@ -453,6 +452,7 @@ def with_clamp( self, mrid: Optional[str] = None, length_from_terminal_1: float = None, + nominal_phases: PhaseCode = PhaseCode.ABC, action: Callable[[Clamp], None] = null_action ) -> 'TestNetworkBuilder': """ @@ -460,6 +460,7 @@ def with_clamp( :param mrid: Optional mRID for the new `Clamp` :param length_from_terminal_1: The length from terminal 1 of the `AcLineSegment` being clamped + :param nominal_phases: The nominal phases for the new `BusbarSection`. :param action: An action that accepts the new `Clamp` to allow for additional initialisation. :return: This `TestNetworkBuilder` to allow for fluent use @@ -469,7 +470,7 @@ def with_clamp( 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')) + self._add_terminal(clamp, 1, nominal_phases) acls.add_clamp(clamp) action(clamp) @@ -482,8 +483,9 @@ def with_cut( length_from_terminal_1: Optional[float] = None, is_normally_open: bool = True, is_open: bool = None, + nominal_phases: PhaseCode = PhaseCode.ABC, action: Callable[[Cut], None] = null_action - ) -> 'TestNetworkBuilder': + ) -> 'TestNetworkBuilder': """ Create a cut on the current network pointer (must be an `AcLineSegment`) without moving the current network pointer. @@ -491,6 +493,7 @@ def with_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 nominal_phases: The nominal phases for the new `BusbarSection`. :param action: An action that accepts the new `Cut` to allow for additional initialisation. :return: This `TestNetworkBuilder` to allow for fluent use @@ -500,8 +503,8 @@ def with_cut( 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')) + for i in [1, 2]: + self._add_terminal(cut, i, nominal_phases) cut.set_normally_open(is_normally_open) if is_open is None: @@ -625,10 +628,10 @@ def add_site(self, equipment_mrids: List[str], mrid: Optional[str] = None) -> 'T 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: + async def build(self, apply_directions_from_sources: bool = True, debug_logger: Logger = None) -> NetworkService: """ Get the `NetworkService` after apply traced phasing and feeder directions. @@ -636,22 +639,21 @@ async def build(self, apply_directions_from_sources: bool = True, assign_feeders :return: The `NetworkService` created by this `TestNetworkBuilder` """ - await Tracing.set_direction().run(self.network, network_state_operators=NetworkStateOperators.NORMAL) - await Tracing.set_phases().run(self.network, network_state_operators=NetworkStateOperators.NORMAL) - await Tracing.set_direction().run(self.network, network_state_operators=NetworkStateOperators.CURRENT) - await Tracing.set_phases().run(self.network, network_state_operators=NetworkStateOperators.CURRENT) + await Tracing.set_direction(debug_logger=debug_logger).run(self.network, network_state_operators=NetworkStateOperators.NORMAL) + await Tracing.set_direction(debug_logger=debug_logger).run(self.network, network_state_operators=NetworkStateOperators.CURRENT) + await Tracing.set_phases(debug_logger=debug_logger).run(self.network, network_state_operators=NetworkStateOperators.NORMAL) + await Tracing.set_phases(debug_logger=debug_logger).run(self.network, network_state_operators=NetworkStateOperators.CURRENT) if apply_directions_from_sources: for es in self.network.objects(EnergySource): for terminal in es.terminals: - await Tracing.set_direction().run_terminal(terminal, network_state_operators=NetworkStateOperators.NORMAL) - await Tracing.set_direction().run_terminal(terminal, network_state_operators=NetworkStateOperators.CURRENT) + await Tracing.set_direction(debug_logger=debug_logger).run_terminal(terminal, network_state_operators=NetworkStateOperators.NORMAL) + await Tracing.set_direction(debug_logger=debug_logger).run_terminal(terminal, network_state_operators=NetworkStateOperators.CURRENT) - if assign_feeders and (self.network.len_of(Feeder) != 0 or self.network.len_of(LvFeeder) != 0): - await Tracing.assign_equipment_to_feeders().run(self.network, network_state_operators=NetworkStateOperators.NORMAL) - await Tracing.assign_equipment_to_lv_feeders().run(self.network, network_state_operators=NetworkStateOperators.NORMAL) - await Tracing.assign_equipment_to_feeders().run(self.network, network_state_operators=NetworkStateOperators.CURRENT) - await Tracing.assign_equipment_to_lv_feeders().run(self.network, network_state_operators=NetworkStateOperators.CURRENT) + await Tracing.assign_equipment_to_feeders(debug_logger=debug_logger).run(self.network, network_state_operators=NetworkStateOperators.NORMAL) + await Tracing.assign_equipment_to_lv_feeders(debug_logger=debug_logger).run(self.network, network_state_operators=NetworkStateOperators.NORMAL) + await Tracing.assign_equipment_to_feeders(debug_logger=debug_logger).run(self.network, network_state_operators=NetworkStateOperators.CURRENT) + await Tracing.assign_equipment_to_lv_feeders(debug_logger=debug_logger).run(self.network, network_state_operators=NetworkStateOperators.CURRENT) return self.network @@ -807,4 +809,3 @@ def _add_terminal(self, ce: ConductingEquipment, sn: int, nominal_phases: PhaseC terminal = Terminal(mrid=f"{ce.mrid}-t{sn}", phases=nominal_phases) ce.add_terminal(terminal) self.network.add(terminal) - diff --git a/src/zepben/evolve/util.py b/src/zepben/evolve/util.py index a9109ba81..b80592a40 100644 --- a/src/zepben/evolve/util.py +++ b/src/zepben/evolve/util.py @@ -5,8 +5,21 @@ 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", "classproperty"] +__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", + "classproperty", +] import os import re @@ -133,7 +146,7 @@ def require(condition: bool, lazy_message: Callable[[], Any]): def pb_or_none(cim: Optional[Any]): - """ Convert to a protobuf type or return None if cim was None """ + """Convert to a protobuf type or return None if cim was None""" return cim.to_pb() if cim is not None else None diff --git a/test/services/network/tracing/networktrace/__init__.py b/test/services/network/tracing/networktrace/__init__.py new file mode 100644 index 000000000..e7d95cd55 --- /dev/null +++ b/test/services/network/tracing/networktrace/__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/test/services/network/tracing/networktrace/actions/test_equipment_tree_builder.py b/test/services/network/tracing/networktrace/actions/test_equipment_tree_builder.py index 250bb1cf9..2834d7875 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 @@ -3,16 +3,15 @@ # 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 pprint -from collections import deque, defaultdict +from collections import deque 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 import downstream, NetworkTraceActionType 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 @@ -34,7 +33,9 @@ async def test_downstream_tree(): start = n.get("j1", ConductingEquipment) assert start is not None tree_builder = EquipmentTreeBuilder() - trace = Tracing.network_trace_branching(network_state_operators=normal, action_step_type=NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT) \ + 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) \ .add_step_action(lambda item, context: visited_ce.append(item.path.to_equipment.mrid)) diff --git a/test/services/network/tracing/networktrace/test_network_trace.py b/test/services/network/tracing/networktrace/test_network_trace.py index 2b5109a00..895e4d13a 100644 --- a/test/services/network/tracing/networktrace/test_network_trace.py +++ b/test/services/network/tracing/networktrace/test_network_trace.py @@ -6,13 +6,13 @@ import sys DEFAULT_RECURSION_LIMIT = sys.getrecursionlimit() -from typing import List, Set +from typing import List, Set, Tuple 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 import AcLineSegment, Clamp, Terminal, NetworkTraceStep, Cut, ConductingEquipment, TraversalQueue, Junction, ngen, NetworkTraceActionType, \ + Tracing from zepben.evolve.testing.test_network_builder import TestNetworkBuilder Terminal.__add__ = PathTerminal.__add__ @@ -39,7 +39,7 @@ def test_adds_start_whole_clamp_as_not_traversed_segment_path(self): segment.add_clamp(clamp) trace.add_start_item(clamp) - _verify_paths(ngen([trace.start_items[0].path]), (clamp[1] + clamp[1], )) + _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): @@ -129,12 +129,12 @@ async def test_breadth_first_queue_supports_multiple_start_items(self): .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')] + == [(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): @@ -153,10 +153,10 @@ async def test_can_stop_on_start_item_when_running_from_conducting_equipment(sel .run(ns.get('b0', ConductingEquipment)) assert list(map(lambda it: (it.num_equipment_steps, it.path.to_equipment.mrid), steps)) \ - == [(0, 'b0')] + == [(0, 'b0')] @pytest.mark.asyncio - async def test_can_Stop_on_start_item_when_running_from_conducting_equipment_branching(self): + async def test_can_stop_on_start_item_when_running_from_conducting_equipment_branching(self): # # 1 b0 21--c1--2 # 1 @@ -188,13 +188,13 @@ async def test_can_run_large_branching_traces(self): network = builder.network builder.from_junction(num_terminals=1) \ - .to_acls() + .to_acls() for i in range(1000): 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) + .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)) @@ -225,3 +225,61 @@ def stop_condition(item, _): 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'] + + @pytest.mark.asyncio + async def test_can_provide_a_path_to_force_the_trace_to_traverse_in_a_given_direction(self): + # + # 1--c0--21--c1-*-21--c2--2 + # 1 + # 1--c3--2 + # + + def create_start_path(start: Tuple[str, str]): + + _from_ce = (_from := ns.get(start[0], Terminal)).conducting_equipment + _to_ce = (_to := ns.get(start[1], Terminal)).conducting_equipment + + def traversed(): + if (_to_ce == _from_ce) and isinstance(_to_ce, AcLineSegment): + return _to_ce + elif isinstance(_to_ce, Clamp) and _to_ce.ac_line_segment == _from_ce: + return _to_ce.ac_line_segment + elif isinstance(_from_ce, Clamp) and _from_ce.ac_line_segment == _to_ce: + return _from_ce.ac_line_segment + return None + + return NetworkTraceStep.Path(_from, _to, traversed()) + + async def validate(start: Tuple[str, str], action_step_type: NetworkTraceActionType, expected: List[str]): + stepped_on: List[NetworkTraceStep] = [] + + await (Tracing.network_trace(action_step_type=action_step_type) + .add_step_action(lambda item, ctx: stepped_on.append(item)) + ).run(create_start_path(start)) + + assert [it.path.to_terminal.mrid for it in stepped_on] == expected + + ns = (TestNetworkBuilder() + .from_acls() # c0 + .to_acls() # c1 + .with_clamp() # c1-clamp1 + .to_acls() # c2 + .branch_from('c1-clamp1') + .to_acls() # c3 + ).network + + await validate(('c0-t1', 'c0-t2'), NetworkTraceActionType.ALL_STEPS, ["c0-t2", "c1-t1", "c1-t2", "c2-t1", "c2-t2", "c1-clamp1-t1", "c3-t1", "c3-t2"]) + await validate(('c0-t2', 'c0-t1'), NetworkTraceActionType.ALL_STEPS, ["c0-t1"]) + await validate(('c1-t2', 'c2-t1'), NetworkTraceActionType.ALL_STEPS, ["c2-t1", "c2-t2"]) + await validate(('c1-t1', 'c1-clamp1-t1'), NetworkTraceActionType.ALL_STEPS, ["c1-clamp1-t1", "c3-t1", "c3-t2"]) + await validate(('c1-clamp1-t1', 'c1-t2'), NetworkTraceActionType.ALL_STEPS, ["c1-t2", "c2-t1", "c2-t2"]) + + await validate(('c0-t1', 'c0-t2'), NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, ["c0-t2", "c1-t1", "c2-t1", "c1-clamp1-t1", "c3-t1"]) + await validate(('c0-t2', 'c0-t1'), NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, ["c0-t1"]) + await validate(('c1-t2', 'c2-t1'), NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, ["c2-t1"]) + await validate(('c1-t1', 'c1-clamp1-t1'), NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, ["c1-clamp1-t1", "c3-t1"]) + await validate(('c1-clamp1-t1', 'c1-t2'), NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, ["c1-t2", "c2-t1"]) + + # Can even use bizarre paths, they are just the same as any other external path. + await validate(('c0-t1', 'c2-t1'), NetworkTraceActionType.ALL_STEPS, ["c2-t1", "c2-t2"]) + await validate(('c0-t1', 'c2-t1'), NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, ["c2-t1"]) diff --git a/test/services/network/tracing/phases/test_phase_inferrer.py b/test/services/network/tracing/phases/test_phase_inferrer.py index 65141073d..4ada1344c 100644 --- a/test/services/network/tracing/phases/test_phase_inferrer.py +++ b/test/services/network/tracing/phases/test_phase_inferrer.py @@ -9,8 +9,8 @@ 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 +from zepben.evolve.database.sqlite.network.network_database_reader import NetworkDatabaseReader A = SinglePhaseKind.A B = SinglePhaseKind.B @@ -18,6 +18,7 @@ N = SinglePhaseKind.N NONE = SinglePhaseKind.NONE + class TestPhaseInferrer: """ Test the `PhaseInferrer` @@ -446,13 +447,15 @@ async def test_validate_directions_with_dropped_direction_loop(self, caplog): self._validate_returned_phases(network, changes, ['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 [] + + current = [] + if do_current: + current = await PhaseInferrer().run(network, network_state_operators=NetworkStateOperators.CURRENT) # 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. diff --git a/test/services/network/tracing/phases/test_set_phases.py b/test/services/network/tracing/phases/test_set_phases.py index 9345b89cc..06d6b327e 100644 --- a/test/services/network/tracing/phases/test_set_phases.py +++ b/test/services/network/tracing/phases/test_set_phases.py @@ -2,31 +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 Union, List + import pytest from network_fixtures import phase_swap_loop_network # noqa (Fixtures) from services.network.tracing.phases.util import connected_equipment_trace_with_logging, validate_phases, validate_phases_from_term_or_equip, get_t -from zepben.evolve import SetPhases, EnergySource, ConductingEquipment, SinglePhaseKind as SPK, TestNetworkBuilder, PhaseCode, Breaker, NetworkStateOperators, \ - Traversal, StepContext +from zepben.evolve import SetPhases, EnergySource, ConductingEquipment, SinglePhaseKind as SPK, TestNetworkBuilder, PhaseCode, Breaker, NetworkStateOperators from zepben.evolve.exceptions import TracingException, PhaseException -from zepben.evolve.services.network.tracing.networktrace.network_trace import NetworkTrace -from zepben.evolve.services.network.tracing.networktrace.network_trace_step import NetworkTraceStep - - -class LoggingSetPhases(SetPhases) : - def __init__(self): - super().__init__() - self.step_count = 0 - - async def _create_network_trace(self, state_operators: NetworkStateOperators) -> NetworkTrace[SetPhases.PhasesToFlow]: - def log_step(nts: NetworkTraceStep, context: StepContext): - print(f'{nts.path.from_terminal}->{nts.path.to_terminal} :: {nts.path.from_terminal.phases} >< {nts.path.to_terminal.phases}') - - return (await super()._create_network_trace(state_operators)) \ - .add_step_action(log_step) - -SetPhases = LoggingSetPhases @pytest.mark.asyncio @pytest.mark.parametrize('phase_swap_loop_network', [(False,)], indirect=True) @@ -178,8 +162,8 @@ async def test_must_provide_the_correct_number_of_phases(): await connected_equipment_trace_with_logging(network_service.objects(EnergySource)) with pytest.raises(TracingException) as e_info: - await SetPhases()._run_with_phases(get_t(network_service, "c0", 2), PhaseCode.AB, network_state_operators=NetworkStateOperators.NORMAL) - await SetPhases()._run_with_phases(get_t(network_service, "c0", 2), PhaseCode.AB, network_state_operators=NetworkStateOperators.CURRENT) + await SetPhases().run(get_t(network_service, "c0", 2), PhaseCode.AB, network_state_operators=NetworkStateOperators.NORMAL) + await SetPhases().run(get_t(network_service, "c0", 2), PhaseCode.AB, network_state_operators=NetworkStateOperators.CURRENT) assert str(e_info.value) == "Attempted to apply phases [A, B] to Terminal{c0-t2} with nominal phases A. Number of phases to apply must match the " \ "number of nominal phases. Found 2, expected 1" @@ -205,7 +189,7 @@ async def test_detects_cross_phasing_flow(): await SetPhases().run(get_t(network_service, "c0", 2), network_state_operators=NetworkStateOperators.CURRENT) assert e_info.value.args[0] == f"Attempted to flow conflicting phase A onto B on nominal phase A. This occurred while flowing from " \ - f"{list(c1.terminals)[0]} to {list(c1.terminals)[1]} through {c1}. This is caused by missing open " \ + f"{list(c1.terminals)[0]} to {list(c1.terminals)[1]} through {c1}. This is often caused by missing open " \ f"points, or incorrect phases in upstream equipment that should be corrected in the source data." @@ -231,10 +215,293 @@ async def test_detects_cross_phasing_connected(): await SetPhases().run(get_t(network_service, "c0", 2), network_state_operators=NetworkStateOperators.CURRENT) assert e_info.value.args[0] == f"Attempted to flow conflicting phase A onto B on nominal phase A. This occurred while flowing between " \ - f"{list(c1.terminals)[1]} on {c1} and {list(c2.terminals)[0]} on {c2}. This is caused by " \ + f"{list(c1.terminals)[1]} on {c1} and {list(c2.terminals)[0]} on {c2}. This is often caused by " \ f"missing open points, or incorrect phases in upstream equipment that should be corrected in the source data." +@pytest.mark.asyncio +async def test_adds_neutral_through_transformers(): + # + # s0 11--tx1--21--c2--2 + # + n = await (TestNetworkBuilder() + .from_source(PhaseCode.ABC) # s0 + .to_power_transformer([PhaseCode.ABC, PhaseCode.ABCN]) # tx1 + .to_acls(PhaseCode.ABCN) # c2 + ).build() + + validate_phases_from_term_or_equip(n, 's0', PhaseCode.ABC) + validate_phases_from_term_or_equip(n, 'tx1', PhaseCode.ABC, PhaseCode.ABCN) + validate_phases_from_term_or_equip(n, 'c2', PhaseCode.ABCN, PhaseCode.ABCN) + + +@pytest.mark.asyncio +async def test_applies_unknown_phases_through_transformers(): + # + # s0 11--tx1--21--c2--2 + # + n = await (TestNetworkBuilder() + .from_source(PhaseCode.BC) # s0 + .to_power_transformer([PhaseCode.BC, PhaseCode.XN]) # tx1 + .to_acls(PhaseCode.XN) # c2 + ).build() + + validate_phases_from_term_or_equip(n, 's0', PhaseCode.BC) + validate_phases_from_term_or_equip(n, 'tx1', PhaseCode.BC, PhaseCode.BN) + validate_phases_from_term_or_equip(n, 'c2', PhaseCode.BN, PhaseCode.BN) + + +@pytest.mark.asyncio +async def test_energises_transformer_phases_straight(): + # Without neutral. + await _validate_tx_phases(*[PhaseCode.ABC] * 5) + + await _validate_tx_phases(*[PhaseCode.AB] * 5) + await _validate_tx_phases(*[PhaseCode.BC] * 5) + await _validate_tx_phases(*[PhaseCode.AC] * 5) + + await _validate_tx_phases(PhaseCode.AB, PhaseCode.AB, PhaseCode.XY, PhaseCode.AB, PhaseCode.AB) + await _validate_tx_phases(PhaseCode.BC, PhaseCode.BC, PhaseCode.XY, PhaseCode.BC, PhaseCode.BC) + await _validate_tx_phases(PhaseCode.AC, PhaseCode.AC, PhaseCode.XY, PhaseCode.AC, PhaseCode.AC) + + await _validate_tx_phases(PhaseCode.AB, PhaseCode.XY, PhaseCode.XY, PhaseCode.AB, PhaseCode.AB) + await _validate_tx_phases(PhaseCode.BC, PhaseCode.XY, PhaseCode.XY, PhaseCode.BC, PhaseCode.BC) + await _validate_tx_phases(PhaseCode.AC, PhaseCode.XY, PhaseCode.XY, PhaseCode.AC, PhaseCode.AC) + + await _validate_tx_phases(*[PhaseCode.A] * 5) + await _validate_tx_phases(*[PhaseCode.B] * 5) + await _validate_tx_phases(*[PhaseCode.C] * 5) + + await _validate_tx_phases(PhaseCode.A, PhaseCode.A, PhaseCode.X, PhaseCode.A, PhaseCode.A) + await _validate_tx_phases(PhaseCode.B, PhaseCode.B, PhaseCode.X, PhaseCode.B, PhaseCode.B) + await _validate_tx_phases(PhaseCode.C, PhaseCode.C, PhaseCode.X, PhaseCode.C, PhaseCode.C) + + await _validate_tx_phases(PhaseCode.A, PhaseCode.X, PhaseCode.X, PhaseCode.A, PhaseCode.A) + await _validate_tx_phases(PhaseCode.B, PhaseCode.X, PhaseCode.X, PhaseCode.B, PhaseCode.B) + await _validate_tx_phases(PhaseCode.C, PhaseCode.X, PhaseCode.X, PhaseCode.C, PhaseCode.C) + + # With neutral. + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.ABC, PhaseCode.ABCN, PhaseCode.ABC, PhaseCode.ABCN) + + await _validate_tx_phases(PhaseCode.AB, PhaseCode.AB, PhaseCode.ABN, PhaseCode.AB, PhaseCode.ABN) + await _validate_tx_phases(PhaseCode.BC, PhaseCode.BC, PhaseCode.BCN, PhaseCode.BC, PhaseCode.BCN) + await _validate_tx_phases(PhaseCode.AC, PhaseCode.AC, PhaseCode.ACN, PhaseCode.AC, PhaseCode.ACN) + + await _validate_tx_phases(PhaseCode.AB, PhaseCode.AB, PhaseCode.XYN, PhaseCode.AB, PhaseCode.ABN) + await _validate_tx_phases(PhaseCode.BC, PhaseCode.BC, PhaseCode.XYN, PhaseCode.BC, PhaseCode.BCN) + await _validate_tx_phases(PhaseCode.AC, PhaseCode.AC, PhaseCode.XYN, PhaseCode.AC, PhaseCode.ACN) + + await _validate_tx_phases(PhaseCode.AB, PhaseCode.XY, PhaseCode.XYN, PhaseCode.AB, PhaseCode.ABN) + await _validate_tx_phases(PhaseCode.BC, PhaseCode.XY, PhaseCode.XYN, PhaseCode.BC, PhaseCode.BCN) + await _validate_tx_phases(PhaseCode.AC, PhaseCode.XY, PhaseCode.XYN, PhaseCode.AC, PhaseCode.ACN) + + await _validate_tx_phases(PhaseCode.A, PhaseCode.A, PhaseCode.AN, PhaseCode.A, PhaseCode.AN) + await _validate_tx_phases(PhaseCode.B, PhaseCode.B, PhaseCode.BN, PhaseCode.B, PhaseCode.BN) + await _validate_tx_phases(PhaseCode.C, PhaseCode.C, PhaseCode.CN, PhaseCode.C, PhaseCode.CN) + + await _validate_tx_phases(PhaseCode.A, PhaseCode.A, PhaseCode.XN, PhaseCode.A, PhaseCode.AN) + await _validate_tx_phases(PhaseCode.B, PhaseCode.B, PhaseCode.XN, PhaseCode.B, PhaseCode.BN) + await _validate_tx_phases(PhaseCode.C, PhaseCode.C, PhaseCode.XN, PhaseCode.C, PhaseCode.CN) + + await _validate_tx_phases(PhaseCode.A, PhaseCode.X, PhaseCode.XN, PhaseCode.A, PhaseCode.AN) + await _validate_tx_phases(PhaseCode.B, PhaseCode.X, PhaseCode.XN, PhaseCode.B, PhaseCode.BN) + await _validate_tx_phases(PhaseCode.C, PhaseCode.X, PhaseCode.XN, PhaseCode.C, PhaseCode.CN) + + +@pytest.mark.asyncio +async def test_energises_transformer_phases_added(): + # + # NOTE: When adding a Y phase to an X -> XY transformer that is downstream of a C, the C phase will be spread on the X and the Y + # will be left de-energised. + # + # You could rework it so this works as intended, but there are dramatic flow on effects making sure the XY (AC) is correctly + # connected at the other end to follow up equipment with non XY phases. Given this is only an issue where the phases of the + # transformer are unknown, and this is a SWER to split-phase transformer that happens to be on the end of a C phase SWER line, and + # you can resolve it by specifying the transformer phases explicitly (i.e. C -> ACN), it won't be fixed for now. + # + + # Without neutral. + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.A, PhaseCode.AB, PhaseCode.A, PhaseCode.AB) + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.B, PhaseCode.BC, PhaseCode.B, PhaseCode.BC) + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.C, PhaseCode.AC, PhaseCode.C, PhaseCode.AC) + + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.A, PhaseCode.XY, PhaseCode.A, PhaseCode.AB) + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.B, PhaseCode.XY, PhaseCode.B, PhaseCode.BC) + # As per the note above, this is not ideal. Ideally the note above would be removed and the test below would be replaced with + # `await _validate_tx_phases(PhaseCode.ABC, PhaseCode.C, PhaseCode.XY, PhaseCode.C, PhaseCode.AC)` and the single phase variant of + # await _validate_tx_phases would be removed. + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.C, PhaseCode.XY, PhaseCode.C, [SPK.C, SPK.NONE]) + + await _validate_tx_phases(PhaseCode.A, PhaseCode.X, PhaseCode.XY, PhaseCode.A, PhaseCode.AB) + await _validate_tx_phases(PhaseCode.B, PhaseCode.X, PhaseCode.XY, PhaseCode.B, PhaseCode.BC) + + # As per the note above, this is not ideal. Ideally the note above would be removed and the test below would be replaced with + # `await _validate_tx_phases(PhaseCode.C, PhaseCode.X, PhaseCode.XY, PhaseCode.C, PhaseCode.AC)` and the single phase variant of + # await _validate_tx_phases would be removed. + await _validate_tx_phases(PhaseCode.C, PhaseCode.X, PhaseCode.XY, PhaseCode.C, [SPK.C, SPK.NONE]) + + # With neutral. + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.A, PhaseCode.ABN, PhaseCode.A, PhaseCode.ABN) + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.B, PhaseCode.BCN, PhaseCode.B, PhaseCode.BCN) + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.C, PhaseCode.ACN, PhaseCode.C, PhaseCode.ACN) + + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.A, PhaseCode.XYN, PhaseCode.A, PhaseCode.ABN) + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.B, PhaseCode.XYN, PhaseCode.B, PhaseCode.BCN) + # As per the note above, this is not ideal. Ideally the note above would be removed and the test below would be replaced with + # `await _validate_tx_phases(PhaseCode.ABC, PhaseCode.C, PhaseCode.XYN, PhaseCode.C, PhaseCode.ACN)` and the single phase variant of + # await _validate_tx_phases would be removed. + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.C, PhaseCode.XYN, PhaseCode.C, [SPK.C, SPK.NONE, SPK.N]) + + await _validate_tx_phases(PhaseCode.A, PhaseCode.X, PhaseCode.XYN, PhaseCode.A, PhaseCode.ABN) + await _validate_tx_phases(PhaseCode.B, PhaseCode.X, PhaseCode.XYN, PhaseCode.B, PhaseCode.BCN) + + # As per the note above, this is not ideal. Ideally the note above would be removed and the test below would be replaced with + # `await _validate_tx_phases(PhaseCode.C, PhaseCode.X, PhaseCode.XY, PhaseCode.C, PhaseCode.AC)` and the single phase variant of + # await _validate_tx_phases would be removed. + await _validate_tx_phases(PhaseCode.C, PhaseCode.X, PhaseCode.XYN, PhaseCode.C, [SPK.C, SPK.NONE, SPK.N]) + + +@pytest.mark.asyncio +async def test_energises_transformer_phases_dropped(): + # + # NOTE: When dropping a Y phase to an XY -> X transformer that is downstream of an AC, the A phase will be spread on the X, + # and the C phase will be dropped. + # + # You could rework it so this works as intended, but there are dramatic flow on effects making sure the XY (AC) is correctly + # connected at the other end to follow up equipment with non XY phases. Given this is only an issue where the phases of the + # transformer are unknown, and this is a split-phase to SWER transformer that happens to be on the end of an AC line, and + # you can resolve it by specifying the transformer phases explicitly (i.e. ACN -> C), it won't be fixed for now. + # + + # Without neutral. + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.AB, PhaseCode.A, PhaseCode.AB, PhaseCode.A) + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.BC, PhaseCode.B, PhaseCode.BC, PhaseCode.B) + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.AC, PhaseCode.C, PhaseCode.AC, PhaseCode.C) + + await _validate_tx_phases(PhaseCode.AB, PhaseCode.XY, PhaseCode.A, PhaseCode.AB, PhaseCode.A) + await _validate_tx_phases(PhaseCode.BC, PhaseCode.XY, PhaseCode.B, PhaseCode.BC, PhaseCode.B) + + # As per the note above, this is not ideal. Ideally the note above would be removed and the test below would be replaced with + # `await _validate_tx_phases(PhaseCode.AC, PhaseCode.XY, PhaseCode.C, PhaseCode.AC, PhaseCode.C)`. + await _validate_tx_phases(PhaseCode.AC, PhaseCode.XY, PhaseCode.C, PhaseCode.AC, PhaseCode.A) + + await _validate_tx_phases(PhaseCode.AB, PhaseCode.XY, PhaseCode.X, PhaseCode.AB, PhaseCode.A) + await _validate_tx_phases(PhaseCode.BC, PhaseCode.XY, PhaseCode.X, PhaseCode.BC, PhaseCode.B) + + # As per the note above, this is not ideal. Ideally the note above would be removed and the test below would be replaced with + # `await _validate_tx_phases(PhaseCode.AC, PhaseCode.XY, PhaseCode.X, PhaseCode.AC, PhaseCode.C)`. + await _validate_tx_phases(PhaseCode.AC, PhaseCode.XY, PhaseCode.X, PhaseCode.AC, PhaseCode.A) + + # With neutral. + await _validate_tx_phases(PhaseCode.ABCN, PhaseCode.ABN, PhaseCode.A, PhaseCode.ABN, PhaseCode.A) + await _validate_tx_phases(PhaseCode.ABCN, PhaseCode.BCN, PhaseCode.B, PhaseCode.BCN, PhaseCode.B) + await _validate_tx_phases(PhaseCode.ABCN, PhaseCode.ACN, PhaseCode.C, PhaseCode.ACN, PhaseCode.C) + + await _validate_tx_phases(PhaseCode.ABN, PhaseCode.XYN, PhaseCode.A, PhaseCode.ABN, PhaseCode.A) + await _validate_tx_phases(PhaseCode.BCN, PhaseCode.XYN, PhaseCode.B, PhaseCode.BCN, PhaseCode.B) + # As per the note above, this is not ideal. Ideally the note above would be removed and the test below would be replaced with + # `await _validate_tx_phases(PhaseCode.ACN, PhaseCode.XYN, PhaseCode.C, PhaseCode.ACN, PhaseCode.C)`. + await _validate_tx_phases(PhaseCode.ACN, PhaseCode.XYN, PhaseCode.C, PhaseCode.ACN, PhaseCode.A) + + await _validate_tx_phases(PhaseCode.ABN, PhaseCode.XYN, PhaseCode.X, PhaseCode.ABN, PhaseCode.A) + await _validate_tx_phases(PhaseCode.BCN, PhaseCode.XYN, PhaseCode.X, PhaseCode.BCN, PhaseCode.B) + + # As per the note above, this is not ideal. Ideally the note above would be removed and the test below would be replaced with + # `await _validate_tx_phases(PhaseCode.ACN, PhaseCode.XYN, PhaseCode.X, PhaseCode.ACN, PhaseCode.C)`. + await _validate_tx_phases(PhaseCode.ACN, PhaseCode.XYN, PhaseCode.X, PhaseCode.ACN, PhaseCode.A) + + +@pytest.mark.asyncio +async def test_applies_phases_to_unknown_hv(): + # + # s0 11--c1--21--c2--2 + # + n = await (TestNetworkBuilder() + .from_source(PhaseCode.BC) # s0 + .to_acls(PhaseCode.BC) # c1 + .to_acls(PhaseCode.XY) # c2 + ).build() + + validate_phases_from_term_or_equip(n, 's0', PhaseCode.BC) + validate_phases_from_term_or_equip(n, 'c1', PhaseCode.BC, PhaseCode.BC) + validate_phases_from_term_or_equip(n, 'c2', PhaseCode.BC, PhaseCode.BC) + + +@pytest.mark.asyncio +async def test_applies_phases_to_unknown_lv(): + # + # s0 11--c1--21--c2--2 + # + n = await (TestNetworkBuilder() + .from_source(PhaseCode.CN) # s0 + .to_acls(PhaseCode.CN) # c1 + .to_acls(PhaseCode.XN) # c2 + ).build() + + validate_phases_from_term_or_equip(n, 's0', PhaseCode.CN) + validate_phases_from_term_or_equip(n, 'c1', PhaseCode.CN, PhaseCode.CN) + validate_phases_from_term_or_equip(n, 'c2', PhaseCode.CN, PhaseCode.CN) + + +@pytest.mark.asyncio +async def test_applies_phases_on_to_swerv(): + # + # s0 11--tx1--21--c2--2 + # + n = await (TestNetworkBuilder() + .from_source(PhaseCode.AC) # s0 + .to_power_transformer([PhaseCode.AC, PhaseCode.X]) # tx1 + .to_acls(PhaseCode.X) # c2 + ).build() + + validate_phases_from_term_or_equip(n, 's0', PhaseCode.AC) + validate_phases_from_term_or_equip(n, 'tx1', PhaseCode.AC, PhaseCode.C) + validate_phases_from_term_or_equip(n, 'c2', PhaseCode.C, PhaseCode.C) + + +@pytest.mark.asyncio +async def test_uses_transformer_paths(): + # + # s0 11--tx1--21--c2--2 + # + n = await (TestNetworkBuilder() + .from_source(PhaseCode.AC) # s0 + .to_power_transformer([PhaseCode.AC, PhaseCode.CN]) # tx1 + .to_acls(PhaseCode.CN) # c2 + ).build() + + validate_phases_from_term_or_equip(n, 's0', PhaseCode.AC) + validate_phases_from_term_or_equip(n, 'tx1', PhaseCode.AC, PhaseCode.CN) + validate_phases_from_term_or_equip(n, 'c2', PhaseCode.CN, PhaseCode.CN) + + +@pytest.mark.asyncio +async def test_does_not_remove_phase_when_applying_subset_out_of_loop(): + # + # s0 12-----c5------1 + # 1 2 + # tx1 tx4 + # 2 1 + # 1--c2--21--c3--2 + # + n = await (TestNetworkBuilder() + .from_source(PhaseCode.ABC) # s0 + .to_power_transformer([PhaseCode.ABC, PhaseCode.ABCN]) # tx1 + .to_acls(PhaseCode.ABCN) # c2 + .to_acls(PhaseCode.CN) # c3 + .to_power_transformer([PhaseCode.CN, PhaseCode.AC]) # tx4 + .to_acls(PhaseCode.ABC) # c5 + .connect('c5', 's0', 2, 1) + ).build() + + validate_phases_from_term_or_equip(n, 's0', PhaseCode.ABC) + validate_phases_from_term_or_equip(n, 'tx1', PhaseCode.ABC, PhaseCode.ABCN) + validate_phases_from_term_or_equip(n, 'c2', PhaseCode.ABCN, PhaseCode.ABCN) + validate_phases_from_term_or_equip(n, 'c3', PhaseCode.CN, PhaseCode.CN) + validate_phases_from_term_or_equip(n, 'tx4', PhaseCode.CN, PhaseCode.AC) + validate_phases_from_term_or_equip(n, 'c5', PhaseCode.ABC, PhaseCode.ABC) + + @pytest.mark.asyncio async def test_can_back_trace_through_xn_xy_transformer_loop(): """ @@ -276,7 +543,12 @@ async def test_can_back_trace_through_xn_xy_transformer_spur(): validate_phases_from_term_or_equip(network_service, "s0", PhaseCode.ABC) validate_phases_from_term_or_equip(network_service, "tx1", PhaseCode.AC, PhaseCode.AN) validate_phases_from_term_or_equip(network_service, "c2", PhaseCode.AN, PhaseCode.AN) - validate_phases_from_term_or_equip(network_service, "tx3", PhaseCode.AN.single_phases, [SPK.A, SPK.NONE]) + # + # NOTE: This is impacted on the XY -> X issue as described elsewhere. If this is fixed you should replace the following test with + # `validate_phases_from_term_or_equip(network_service, "tx3", PhaseCode.AN, PhaseCode.AC)` + # + + validate_phases_from_term_or_equip(network_service, "tx3", PhaseCode.AN, PhaseCode.AB) def _set_normal_phase(terminal_index, from_phase: SPK, to_phase: SPK): @@ -285,6 +557,7 @@ def action(ce: ConductingEquipment): return action + @pytest.mark.asyncio async def test_can_set_phases_from_an_unknown_nominal_phase(): """ @@ -305,3 +578,69 @@ async def test_can_set_phases_from_an_unknown_nominal_phase(): validate_phases_from_term_or_equip(n, 'c0', PhaseCode.NONE, PhaseCode.A) validate_phases_from_term_or_equip(n, 'c1', [SPK.A, SPK.NONE, SPK.NONE], [SPK.A, SPK.NONE, SPK.NONE]) + + +@pytest.mark.asyncio +async def test_energises_around_dropped_phase_dual_transformer_loop(): + # + # This was seen in PCOR data for a dual transformer site (BET006 - RHEOLA P58E) on a SWER line with an LV2 circuit + # + # 21--c3--21 tx4 21--c5--21 + # | | + # c2 | + # | | + # 1 | + # s0 11--c1--2 c6 + # 1 | + # | | + # c7 | + # | | + # 21--c8--21 tx9 21--c10-221--c11-2 + ns = await (TestNetworkBuilder() + .from_source(PhaseCode.A) # s0 + .to_acls(PhaseCode.A) # c1 + .to_acls(PhaseCode.A) # c2 + .to_acls(PhaseCode.A) # c3 + .to_power_transformer([PhaseCode.A, PhaseCode.AN]) # tx4 + .to_acls(PhaseCode.AN) # c5 + .to_acls(PhaseCode.AN) # c6 + .branch_from('c1') + .to_acls(PhaseCode.A) # c7 + .to_acls(PhaseCode.A) # c8 + .to_power_transformer([PhaseCode.A, PhaseCode.ABN]) # tx9 + .to_acls(PhaseCode.ABN) # c10 + .connect_to('c6', 2) + .to_acls(PhaseCode.ABN) # c11 + ).build() + + validate_phases_from_term_or_equip(ns, 'c1', PhaseCode.A, PhaseCode.A) + validate_phases_from_term_or_equip(ns, 'c2', PhaseCode.A, PhaseCode.A) + validate_phases_from_term_or_equip(ns, 'c3', PhaseCode.A, PhaseCode.A) + validate_phases_from_term_or_equip(ns, 'tx4', PhaseCode.A, PhaseCode.AN) + validate_phases_from_term_or_equip(ns, 'c5', PhaseCode.AN, PhaseCode.AN) + validate_phases_from_term_or_equip(ns, 'c6', PhaseCode.AN, PhaseCode.AN) + validate_phases_from_term_or_equip(ns, 'c7', PhaseCode.A, PhaseCode.A) + validate_phases_from_term_or_equip(ns, 'c8', PhaseCode.A, PhaseCode.A) + validate_phases_from_term_or_equip(ns, 'tx9', PhaseCode.A, PhaseCode.ABN) + validate_phases_from_term_or_equip(ns, 'c10', PhaseCode.ABN, PhaseCode.ABN) + validate_phases_from_term_or_equip(ns, 'c11', PhaseCode.ABN, PhaseCode.ABN) + + +async def _validate_tx_phases( + source_phases: PhaseCode, + tx_phase_1: PhaseCode, + tx_phase_2: PhaseCode, + expected_phases_1: PhaseCode, + expected_phases_2: Union[PhaseCode, List[SPK]] +): + if isinstance(expected_phases_2, PhaseCode): + expected_phases_2 = expected_phases_2.single_phases + + n = await (TestNetworkBuilder() + .from_source(source_phases) # s0 + .to_power_transformer([tx_phase_1, tx_phase_2]) # tx1 + .to_acls(tx_phase_2) # c2 + ).build() + validate_phases_from_term_or_equip(n, 's0', source_phases) + validate_phases_from_term_or_equip(n, 'tx1', expected_phases_1.single_phases, expected_phases_2) + validate_phases_from_term_or_equip(n, 'c2', expected_phases_2, expected_phases_2) diff --git a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py new file mode 100644 index 000000000..bdadf871b --- /dev/null +++ b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py @@ -0,0 +1,177 @@ +# 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/. +import logging +import queue +from contextlib import contextmanager + +from zepben.evolve import StepContext, StopCondition, QueueCondition, StepAction +from zepben.evolve.services.network.tracing.traversal.debug_logging import DebugLoggingWrapper + + +def bool_generator(): + while True: + yield True + yield False + + +class TestDebugLoggingWrappers: + class ListHandler(logging.Handler): + log_list = queue.Queue() + + def emit(self, record): + self.log_list.put(self.format(record).rstrip('\n')) + + @contextmanager + def _log_handler(self): + self.logger.addHandler(handler := self.ListHandler()) + try: + yield handler + finally: + self.logger.removeHandler(handler) + + logger = logging.getLogger() + + context_1 = StepContext(True, True) + context_1.__str__ = 'context 1 string' + assert f'{context_1}' == str(context_1) + assert context_1.__str__ == 'context 1 string' + + context_2 = StepContext(True, True) + context_2.__str__ = 'context 2 string' + assert f'{context_2}' == str(context_2) + assert context_2.__str__ == 'context 2 string' + + item_1 = (1, 1.1) + item_2 = (2, 2.2) + + def _wrap(self, condition): + return DebugLoggingWrapper('my desc', self.logger).wrap(condition) + + def test_wrapped_object_is_original_object(self): + should_stop = bool_generator() + + stop_condition = StopCondition(lambda item, ctx: next(should_stop)) + wrapped = self._wrap(stop_condition) + + assert isinstance(wrapped, StopCondition) + assert not isinstance(wrapped, (QueueCondition, StepAction)) + + queue_condition = QueueCondition(lambda nitem, nctx, item, ctx: next(should_stop)) + wrapped = self._wrap(queue_condition) + + assert not isinstance(wrapped, (StopCondition, StepAction)) + assert isinstance(wrapped, QueueCondition) + + action = StepAction(lambda item, context: None) + wrapped = self._wrap(action) + + assert not isinstance(wrapped, (StopCondition, QueueCondition)) + assert isinstance(wrapped, StepAction) + + def test_can_wrap_stop_condition(self): + should_stop = bool_generator() + + wrapped = self._wrap(StopCondition(lambda item, ctx: next(should_stop))) + + with self._log_handler() as handler: + assert wrapped.should_stop(self.item_1, self.context_1) + assert not wrapped.should_stop(self.item_2, self.context_2) + + assert handler.log_list.get() == f"root: my desc: should_stop(1)=True [item={self.item_1}, context={self.context_1}]" + assert handler.log_list.get() == f"root: my desc: should_stop(1)=False [item={self.item_2}, context={self.context_2}]" + + def test_can_wrap_queue_conditions(self): + should_stop = bool_generator() + + condition = QueueCondition(lambda nitem, nctx, item, ctx: next(should_stop)) + condition.should_queue_start_item = lambda item: next(should_stop) + condition = self._wrap(condition) + + with self._log_handler() as handler: + assert condition.should_queue(self.item_1, self.context_1, self.item_2, self.context_2) + assert not condition.should_queue(self.item_2, self.context_2, self.item_1, self.context_1) + + assert next(should_stop) # we need to skip the `True` the generators returning next + + assert not condition.should_queue_start_item(self.item_1) + assert condition.should_queue_start_item(self.item_2) + + assert handler.log_list.get() == ( + f"root: my desc: should_queue(1)=True [" + f"next_item={self.item_1}, next_context={self.context_1}, current_item={self.item_2}, current_context={self.context_2}]" + ) + assert handler.log_list.get() == ( + f"root: my desc: should_queue(1)=False [" + f"next_item={self.item_2}, next_context={self.context_2}, current_item={self.item_1}, current_context={self.context_1}]" + ) + assert handler.log_list.get() == f"root: my desc: should_queue_start_item(1)=False [item={self.item_1}]" + assert handler.log_list.get() == f"root: my desc: should_queue_start_item(1)=True [item={self.item_2}]" + + def test_can_wrap_step_actions(self): + action = self._wrap(StepAction(lambda item, context: None)) + + with self._log_handler() as handler: + action.apply(self.item_1, self.context_1) + action.apply(self.item_2, self.context_2) + + assert handler.log_list.get() == f"root: my desc: stepped_on(1) [item={self.item_1}, context={self.context_1}]" + assert handler.log_list.get() == f"root: my desc: stepped_on(1) [item={self.item_2}, context={self.context_2}]" + + def test_adding_to_debug_logging_wrapper_increments_count_as_expected(self): + logging_wrapper = DebugLoggingWrapper('my desc', self.logger) + + condition = StopCondition(lambda item, context: True) + wrapped_condition = logging_wrapper.wrap(condition) + + # Check count starts at 1, and double adding the same condition doesn't increment count + with self._log_handler() as handler: + wrapped_condition.should_stop(False, False) + assert handler.log_list.get() == f"root: my desc: should_stop(1)=True [item=False, context=False]" + + condition2 = StopCondition(lambda item, context: True) + wrapped_condition2 = logging_wrapper.wrap(condition2) + + with self._log_handler() as handler: + # check the new condition is marked as "2" + wrapped_condition2.should_stop(False, False) + assert handler.log_list.get() == f"root: my desc: should_stop(2)=True [item=False, context=False]" + + # check the original condition hasnt changed from "1" + wrapped_condition.should_stop(False, False) + assert handler.log_list.get() == f"root: my desc: should_stop(1)=True [item=False, context=False]" + + # check that adding the original condition to a new logger works, and resets the count. + logging_wrapper2 = DebugLoggingWrapper('my desc', self.logger) + + wrapped_original_condition = logging_wrapper2.wrap(condition) + wrapped_original_condition2 = logging_wrapper2.wrap(condition2) + + with self._log_handler() as handler: + wrapped_original_condition.should_stop(False, False) + assert handler.log_list.get() == f"root: my desc: should_stop(1)=True [item=False, context=False]" + + # check the new condition is marked as "2" + wrapped_original_condition2.should_stop(False, False) + assert handler.log_list.get() == f"root: my desc: should_stop(2)=True [item=False, context=False]" + + def test_wrapping(self): + wrapper = DebugLoggingWrapper("hmmm", self.logger) + + step_action = StepAction(lambda it, ctx: self.logger.debug(f"{it} {ctx}")) + step_action.apply(1, 2) + + wrapped_1 = wrapper.wrap(step_action) + wrapped_1.apply(1, 2) + + wrapped_2 = wrapper.wrap(wrapped_1) + wrapped_2.apply(1, 2) + + wrapper_2 = DebugLoggingWrapper("hmmm", self.logger) + + wrapped_2_1 = wrapper_2.wrap(step_action) + wrapped_2_1.apply(1, 2) + + wrapped_2_2 = wrapper_2.wrap(wrapped_1) + wrapped_2_2.apply(1, 2) diff --git a/test/services/network/tracing/traversal/test_step_action.py b/test/services/network/tracing/traversal/test_step_action.py new file mode 100644 index 000000000..7ec5a2f45 --- /dev/null +++ b/test/services/network/tracing/traversal/test_step_action.py @@ -0,0 +1,50 @@ +# 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 _pytest.python_api import raises + +from zepben.evolve import StepAction, StepContext +from zepben.evolve.services.network.tracing.traversal.step_action import T + + +class TestStepAction: + + def test_can_apply_lambda(self): + """Make sure we can use a lambda as the StepAction""" + captured = [] + step_action = StepAction(lambda it, ctx: captured.append((it, ctx))) + + expected_item = 1 + expected_ctx = StepContext(is_start_item=True, is_branch_start_item=False) + + step_action.apply(expected_item, expected_ctx) + + assert captured == [(expected_item, expected_ctx)] + + def test_cant_override_apply(self): + """This is testing that if you ignore the @final on apply, you will get an exception.""" + with raises(Exception, match="method 'apply' should not be directly overridden, override '_apply' instead."): + class MyStepAction(StepAction): + + # noinspection PyFinal + def apply(self, item: T, context: StepContext): + pass + + def test_can_apply_descendant(self): + """Simulate someone doing what the exception told you to do""" + captured = [] + + class MyStepAction(StepAction): + + def _apply(self, item: T, context: StepContext): + captured.append((item, context)) + + step_action = MyStepAction() + + expected_item = 1 + expected_ctx = StepContext(is_start_item=True, is_branch_start_item=False) + + step_action.apply(expected_item, expected_ctx) + + assert captured == [(expected_item, expected_ctx)] diff --git a/test/services/network/tracing/traversal/test_traversal.py b/test/services/network/tracing/traversal/test_traversal.py index d2adcf1fa..dd3d46684 100644 --- a/test/services/network/tracing/traversal/test_traversal.py +++ b/test/services/network/tracing/traversal/test_traversal.py @@ -3,22 +3,30 @@ # 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 logging import Logger from typing import Callable, TypeVar, Tuple, Any, Optional import pytest -from zepben.evolve import StepContext, Traversal, TraversalQueue, ContextValueComputer +from zepben.evolve import StepContext, Traversal, TraversalQueue, ContextValueComputer, StepActionWithContextValue, StepAction T = TypeVar('T') D = TypeVar('D') 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]): - super().__init__(queue_type, parent) + name = 'TestTraversal' + + def __init__( + self, + queue_type, + parent: Optional["TraversalTest[T, D]"], + can_visit_item: Callable[[T, StepContext], bool], + can_action_item: Callable[[T, StepContext], bool], + on_reset: Callable[[], Any], + debug_logger: Logger = None, + ): + super().__init__(queue_type, parent, debug_logger=debug_logger) self._can_visit_item_impl = can_visit_item self._can_action_item_impl = can_action_item self._on_reset_impl = on_reset @@ -32,29 +40,27 @@ def can_action_item(self, item: T, context: StepContext) -> bool: def on_reset(self): return self._on_reset_impl() - def create_new_this(self) -> "TraversalTest[int]": + def create_new_this(self) -> "TraversalTest[T, D]": 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, - queue: TraversalQueue[int]=TraversalQueue.depth_first() - ) -> TraversalTest[int, D]: - +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, + queue: TraversalQueue[int] = TraversalQueue.depth_first(), +) -> TraversalTest[int, D]: def queue_next(item, _, queue_item): if item < 0: queue_item(item - 1) else: queue_item(item + 1) - queue_type = Traversal.BasicQueueType[int, TraversalTest[int, D]]( - queue_next=Traversal.QueueNext(queue_next), - queue=queue - ) + queue_type = Traversal.BasicQueueType[int, TraversalTest[int, D]](queue_next=Traversal.QueueNext(queue_next), queue=queue) return TraversalTest(queue_type, None, can_visit_item, can_action_item, on_reset) + def _create_branching_traversal() -> TraversalTest[int, D]: def queue_next(item, _, queue_item, queue_branch): if item == 0: @@ -68,7 +74,7 @@ def queue_next(item, _, queue_item, queue_branch): 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() + branch_queue_factory=lambda: TraversalQueue.depth_first(), ) return TraversalTest(queue_type, None, @@ -76,6 +82,7 @@ def queue_next(item, _, queue_item, queue_branch): can_action_item=lambda x, y: True, on_reset=lambda: None) + class TestTraversal: def setup_method(self, test_method) -> None: @@ -87,10 +94,12 @@ async def test_add_condition_with_stop_condition(self): def step_action(item, _): self.last_num = item - await (_create_traversal() + await ( + _create_traversal() .add_condition(lambda item, _: item == 2) .add_step_action(step_action) - .run(1)) + .run(1) + ) assert self.last_num == 2 @@ -99,10 +108,12 @@ async def test_add_condition_with_queue_condition(self): def step_action(item, _): self.last_num = item - await (_create_traversal() + await ( + _create_traversal() .add_condition(lambda item, x, y, z: item < 3) .add_step_action(step_action) - .run(1)) + .run(1) + ) assert self.last_num == 2 @@ -110,10 +121,12 @@ def step_action(item, _): async def test_stop_conditions(self): steps = [] - await (_create_traversal() + await ( + _create_traversal() .add_stop_condition(lambda item, _: item == 3) .add_step_action(lambda item, ctx: steps.append((item, ctx))) - .run(1)) + .run(1) + ) def check_item_ctx(step: Tuple[int, StepContext], item_val: int, ctx_stopping=False): return step[0] == item_val and step[1].is_stopping == ctx_stopping @@ -127,11 +140,13 @@ async def test_stops_when_matching_any_stop_condition(self): def step_action(item, _): self.last_num = item - await (_create_traversal() + await ( + _create_traversal() .add_stop_condition(lambda item, _: item == 3) .add_stop_condition(lambda item, _: item % 2 == 0) .add_step_action(step_action) - .run(1)) + .run(1) + ) assert self.last_num == 2 @@ -140,11 +155,13 @@ async def test_can_stop_on_start_item_true(self): def step_action(item, _): self.last_num = item - await (_create_traversal() + await ( + _create_traversal() .add_stop_condition(lambda item, _: item == 1) .add_stop_condition(lambda item, _: item == 2) .add_step_action(step_action) - .run(1, can_stop_on_start_item=True)) + .run(1, can_stop_on_start_item=True) + ) assert self.last_num == 1 @@ -153,11 +170,13 @@ async def test_can_stop_on_start_item_false(self): def step_action(item, _): self.last_num = item - await (_create_traversal() - .add_stop_condition(lambda item, _: item == 1) - .add_stop_condition(lambda item, _: item == 2) - .add_step_action(step_action) - .run(1, can_stop_on_start_item=False)) + await ( + _create_traversal() + .add_stop_condition(lambda item, _: item == 1) + .add_stop_condition(lambda item, _: item == 2) + .add_step_action(step_action) + .run(1, can_stop_on_start_item=False) + ) assert self.last_num == 2 @@ -166,10 +185,12 @@ async def test_checks_queue_condition(self): def step_action(item, _): self.last_num = item - await (_create_traversal() + await ( + _create_traversal() .add_queue_condition(lambda next_item, x, y, z: next_item < 3) .add_step_action(step_action) - .run(1)) + .run(1) + ) assert self.last_num == 2 @@ -178,11 +199,13 @@ async def test_queues_when_matching_all_queue_condition(self): def step_action(item, _): self.last_num = item - await (_create_traversal() - .add_queue_condition(lambda next_item, x, y, z: next_item < 3) - .add_queue_condition(lambda next_item, x, y, z: next_item > 3) - .add_step_action(step_action) - .run(1)) + await ( + _create_traversal() + .add_queue_condition(lambda next_item, x, y, z: next_item < 3) + .add_queue_condition(lambda next_item, x, y, z: next_item > 3) + .add_step_action(step_action) + .run(1) + ) assert self.last_num == 1 @@ -191,11 +214,13 @@ async def test_calls_all_registered_step_actions(self): called1 = [] called2 = [] - await (_create_traversal() + await ( + _create_traversal() .add_stop_condition(lambda item, _: item == 2) .add_step_action(lambda x, y: called1.append(True)) .add_step_action(lambda x, y: called2.append(True)) - .run(1)) + .run(1) + ) assert len(called1) == 2 assert len(called2) == 2 @@ -203,56 +228,155 @@ async def test_calls_all_registered_step_actions(self): @pytest.mark.asyncio async def test_if_not_stopping_helper_only_calls_when_not_stopping(self): steps = [] - await (_create_traversal() + await ( + _create_traversal() .add_stop_condition(lambda item, _: item == 3) .if_not_stopping(lambda item, _: steps.append(item)) - .run(1)) + .run(1) + ) + + assert steps == [1, 2] + + @pytest.mark.asyncio + async def test_if_not_stopping_helper_accepts_step_actions(self): + steps = [] + await ( + _create_traversal() + .add_stop_condition(lambda item, _: item == 3) + .if_not_stopping(StepAction(lambda item, _: steps.append(item))) + .run(1) + ) assert steps == [1, 2] @pytest.mark.asyncio async def test_if_stopping_helper_only_calls_when_stopping(self): steps = [] - await (_create_traversal() - .add_stop_condition(lambda item, _: item == 3) - .if_stopping(lambda item, _: steps.append(item)) - .run(1)) + await ( + _create_traversal() + .add_stop_condition(lambda item, _: item == 3) + .if_stopping(lambda item, _: steps.append(item)) + .run(1) + ) assert steps == [3] @pytest.mark.asyncio - async def test_context_value_computer_adds_value_to_context(self): + async def test_if_stopping_helper_accepts_step_actions(self): + steps = [] + await ( + _create_traversal() + .add_stop_condition(lambda item, _: item == 3) + .if_stopping(StepAction(lambda item, _: steps.append(item))) + .run(1) + ) + + assert steps == [3] + + @pytest.mark.asyncio + async def test_if_not_stopping_helper_accepts_step_action_with_context_value_and_context_is_computed(self): + data_capture: dict[int, str] = {} + contex_data_capture = list() + + class TestSAWCV(StepActionWithContextValue[int]): + """We append to `context_data_capture` on every step to ensure that the context is computed on every step.""" + + def compute_next_value(self, next_item: int, current_item: int, current_value): + contex_data_capture.append(True) + return f'{current_value} : (next_item={next_item}, current_item={current_item})' + + def compute_initial_value(self, item: int): + contex_data_capture.append(True) + return f'{item}' + + def step_action(item, ctx: StepContext): + data_capture[item] = ctx.get_value('test') + + await ( + _create_traversal() + .add_stop_condition(lambda item, _: item == 3) + .if_not_stopping(TestSAWCV(_func=step_action, key='test')) + .run(1) + ) + + assert len(data_capture) == 2 + assert data_capture[1] == '1' + assert data_capture[2] == '1 : (next_item=2, current_item=1)' + + # If this fails, either the number of steps changed, or context wasn't computed every step + assert len(contex_data_capture) == 3 + + @pytest.mark.asyncio + async def test_if_stopping_helper_accepts_step_action_with_context_value_and_context_is_computed(self): data_capture: dict[int, str] = {} + contex_data_capture = list() + + class TestSAWCV(StepActionWithContextValue[int]): + """We append to `context_data_capture` on every step to ensure that the context is computed on every step.""" + + def compute_next_value(self, next_item: int, current_item: int, current_value): + contex_data_capture.append(True) + return f'{current_value} : (next_item={next_item}, current_item={current_item})' + + def compute_initial_value(self, item: int): + contex_data_capture.append(True) + return f'{item}' + def step_action(item, ctx: StepContext): data_capture[item] = ctx.get_value('test') + await ( + _create_traversal() + .add_stop_condition(lambda item, _: item == 3) + .if_stopping(TestSAWCV(_func=step_action, key='test')) + .run(1) + ) + + assert len(data_capture) == 1 + assert data_capture[3] == '1 : (next_item=2, current_item=1) : (next_item=3, current_item=2)' + + # If this fails, either the number of steps changed, or context wasn't computed every step + assert len(contex_data_capture) == 3 + + @pytest.mark.asyncio + async def test_context_value_computer_adds_value_to_context(self): + data_capture: dict[int, str] = {} + + def step_action(item, ctx: StepContext): + data_capture[item] = ctx.get_value('test') class TestCVC(ContextValueComputer[int]): def compute_next_value(self, next_item: int, current_item: int, current_value): - return f'{current_value} : {next_item + current_item}' + return f'{current_value} : (next_item={next_item}, current_item={current_item})' + def compute_initial_value(self, item: int): return f'{item}' - await (_create_traversal() + await ( + _create_traversal() .add_context_value_computer(TestCVC('test')) .add_step_action(step_action) .add_stop_condition(lambda item, _: item == 2) - .run(1)) + .run(1) + ) assert data_capture[1] == '1' - assert data_capture[2] == '1 : 3' + assert data_capture[2] == '1 : (next_item=2, current_item=1)' @pytest.mark.asyncio async def test_start_items(self): steps: dict[int, StepContext] = {} + def step_action(item, ctx: StepContext): steps[item] = ctx - traversal = (_create_traversal() - .add_start_item(1) - .add_start_item(-1) - .add_stop_condition(lambda item, _: abs(item) == 2) - .add_step_action(step_action)) + traversal = ( + _create_traversal() + .add_start_item(1) + .add_start_item(-1) + .add_stop_condition(lambda item, _: abs(item) == 2) + .add_step_action(step_action) + ) assert traversal.start_items == deque([1, -1]) await traversal.run() @@ -264,37 +388,43 @@ def step_action(item, ctx: StepContext): async def test_only_visits_items_that_can_be_visited(self): steps = [] - await (_create_traversal(can_visit_item=lambda item, _: item < 0) + await ( + _create_traversal(can_visit_item=lambda item, _: item < 0) .add_stop_condition(lambda item, _: item == -2) .add_step_action(lambda item, _: steps.append(item)) .add_start_item(1) .add_start_item(-1) - .run()) + .run() + ) assert steps == [-1, -2] - @pytest.mark.asyncio 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 == 3) - .add_step_action(lambda item, _: steps.append(item)) - .run(1)) + await ( + _create_traversal(can_action_item=lambda item, _: item % 2 == 1) + .add_stop_condition(lambda item, _: item == 3) + .add_step_action(lambda item, _: steps.append(item)) + .run(1) + ) assert steps == [1, 3] @pytest.mark.asyncio async def test_can_be_rerun(self): steps: dict[int, int] = {} + def step_action(item, _): steps[item] = steps.get(item, 0) + 1 reset_called = [] - traversal = (_create_traversal(on_reset=lambda: reset_called.append(True)) - .add_stop_condition(lambda item, _: item == 2) - .add_step_action(step_action)) + traversal = ( + _create_traversal(on_reset=lambda: reset_called.append(True)) + .add_stop_condition(lambda item, _: item == 2) + .add_step_action(step_action) + ) await traversal.run(1) await traversal.run(2) @@ -306,16 +436,17 @@ def step_action(item, _): @pytest.mark.asyncio async def test_supports_branching_traversals(self): steps: dict[int, StepContext] = {} + def step_action(item, ctx): steps[item] = ctx - trace =(_create_branching_traversal() - .add_queue_condition(lambda item, ctx, x, y: (ctx.branch_depth <= 1) and (item != 0)) - .add_step_action(step_action) - ) + 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 @@ -343,36 +474,39 @@ def stop_condition(item: int, context): 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) + 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) @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() + 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 steps == [-1, 1, -2, 2] @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) - ) + 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]