From 73d1f1e6b0ac2f6ebedd0c2359ac405b0ccf31fa Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Thu, 29 May 2025 02:51:42 +1000 Subject: [PATCH 01/28] Add debug logging to `Traversal` and traces and exposed them via all `Tracing` methods and `TestNetworkBuilder.build`. Just need to write the tests for the actual wrapper Signed-off-by: Max Chesterfield --- src/zepben/evolve/__init__.py | 2 + .../evolve/model/busbranch/bus_branch.py | 9 +- .../tracing/feeder/assign_to_feeders.py | 95 +++++++----- .../tracing/feeder/assign_to_lv_feeders.py | 63 +++++--- .../network/tracing/feeder/clear_direction.py | 29 ++-- .../network/tracing/feeder/set_direction.py | 33 ++-- .../network/tracing/find_swer_equipment.py | 48 +++--- .../actions/equipment_tree_builder.py | 4 +- .../equipment_type_step_limit_condition.py | 8 +- .../tracing/networktrace/network_trace.py | 28 +++- .../networktrace/network_trace_step.py | 10 ++ .../operators/network_state_operators.py | 6 + .../network/tracing/networktrace/tracing.py | 55 ++++--- .../network/tracing/phases/phase_inferrer.py | 20 ++- .../network/tracing/phases/remove_phases.py | 49 +++++- .../network/tracing/phases/set_phases.py | 42 ++--- .../traversal/context_value_computer.py | 55 ++----- .../tracing/traversal/debug_logging.py | 47 ++++++ .../tracing/traversal/queue_condition.py | 14 +- .../network/tracing/traversal/step_action.py | 22 ++- .../network/tracing/traversal/step_context.py | 5 +- .../tracing/traversal/stop_condition.py | 14 +- .../network/tracing/traversal/traversal.py | 146 +++++++++++++----- .../evolve/testing/test_network_builder.py | 35 ++--- .../network/tracing/networktrace/__init__.py | 4 + .../actions/test_equipment_tree_builder.py | 4 +- .../networktrace/test_network_trace.py | 2 +- .../tracing/traversal/test_traversal.py | 27 +++- 28 files changed, 590 insertions(+), 286 deletions(-) create mode 100644 src/zepben/evolve/services/network/tracing/traversal/debug_logging.py create mode 100644 test/services/network/tracing/networktrace/__init__.py diff --git a/src/zepben/evolve/__init__.py b/src/zepben/evolve/__init__.py index 8e5507d11..f64d2b6f5 100644 --- a/src/zepben/evolve/__init__.py +++ b/src/zepben/evolve/__init__.py @@ -156,6 +156,8 @@ from zepben.evolve.services.network.translator.network_cim2proto import * from zepben.evolve.services.network.network_service import * +from zepben.evolve.services.network.tracing.traversal.step_context 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 * diff --git a/src/zepben/evolve/model/busbranch/bus_branch.py b/src/zepben/evolve/model/busbranch/bus_branch.py index 8ace1d6ce..8bcee3915 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", 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..21eb5ba43 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,37 +2,42 @@ # 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.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.traversal.step_context import StepContext +if TYPE_CHECKING: + from zepben.evolve import AuxiliaryEquipment, Equipment, LvFeeder, ConductingEquipment, EquipmentContainer, Terminal + +__all__ = ["AssignToFeeders", "BaseFeedersInternal"] + class AssignToFeeders: + def __init__(self, debug_logger: Logger = None): + self._debug_logger = debug_logger + """ Convenience class that provides methods for assigning HV/MV feeders on a `NetworkService`. Requires that a Feeder have a normalHeadTerminal with associated ConductingEquipment. This class is backed by a `NetworkTrace`. """ - @staticmethod - async def run(network: NetworkService, + async def run(self, + network: NetworkService, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, start_terminal: Terminal=None): """ @@ -44,12 +49,16 @@ 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) @@ -134,10 +143,10 @@ async def run_with_feeders(self, 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..5d6804eef 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,13 +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 __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.network_trace import NetworkTrace @@ -19,28 +18,45 @@ 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: + def __init__(self, debug_logger: Logger=None): + self._debug_logger = debug_logger + @singledispatchmethod - @staticmethod - async def run(network: NetworkService, + async def run(self, + network: NetworkService, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, start_terminal: Terminal=None): - await AssignToLvFeedersInternal(network_state_operators).run(network, start_terminal) + await AssignToLvFeedersInternal( + network_state_operators, + self._debug_logger + ).run(network, start_terminal) @run.register - @staticmethod - async def _(terminal: Terminal, + 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).run_with_feeders(terminal, lv_feeder_start_points, terminal_to_aux_equipment, lv_feeders_to_assign) + 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): """ @@ -109,10 +125,10 @@ def _create_trace(self, 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) + if ce.base_voltage and ce.base_voltage.nominal_voltage >= 1000: + return True + else: + return False 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,11 +137,15 @@ 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) ) @@ -157,10 +177,11 @@ 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) 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..aed14e2b3 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py +++ b/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py @@ -4,7 +4,8 @@ # file, You can obtain one at https://mozilla.org/MPL/2.0/. from __future__ import annotations -from typing import TYPE_CHECKING, Any, Type +from logging import Logger +from typing import TYPE_CHECKING, Any, TypeVar, Type from zepben.evolve.model.cim.iec61970.base.core.terminal import Terminal @@ -14,24 +15,30 @@ 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.conditions.conditions import stop_at_open if TYPE_CHECKING: from zepben.evolve import StepContext, NetworkTraceStep __all__ = ['ClearDirection'] +__all__ =['ClearDirection'] + class ClearDirection: + 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. # async def run(self, - terminal: Terminal, - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL - ) -> list[Terminal]: + 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 @@ -48,12 +55,10 @@ async def run(self, await trace.run(terminal, can_stop_on_start_item=False) return feeder_head_terminals - @staticmethod - def _create_trace(state_operators: Type[NetworkStateOperators], + def _create_trace(self, + state_operators: Type[NetworkStateOperators], visited_feeder_head_terminals: list[Terminal] ) -> NetworkTrace[Any]: - def queue_condition(step: NetworkTraceStep, context: StepContext, _, __): - return state_operators.get_direction(step.path.to_terminal) != FeederDirection.NONE def step_action(item: NetworkTraceStep, context: StepContext): state_operators.set_direction(item.path.to_terminal, FeederDirection.NONE) @@ -63,10 +68,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..c0ac1d26d 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/set_direction.py +++ b/src/zepben/evolve/services/network/tracing/feeder/set_direction.py @@ -1,10 +1,11 @@ -# 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 @@ -21,7 +22,6 @@ 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 @@ -33,6 +33,8 @@ class SetDirection: Convenience class that provides methods for setting feeder direction on a [NetworkService] 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], @@ -80,28 +82,25 @@ def next_direction_func(): async 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 @@ -122,7 +121,7 @@ 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 @@ -137,7 +136,7 @@ async def run_terminal(self, terminal: Terminal, network_state_operators: Type[N 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) 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..76652fb19 100644 --- a/src/zepben/evolve/services/network/tracing/find_swer_equipment.py +++ b/src/zepben/evolve/services/network/tracing/find_swer_equipment.py @@ -1,8 +1,10 @@ -# 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 @@ -11,19 +13,18 @@ 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.model.cim.iec61970.base.wires.switch import Switch +from zepben.evolve.services.network.network_service import NetworkService +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.conditions.conditions import stop_at_open +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,10 @@ 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` @@ -44,8 +48,10 @@ async def find(self, to_process: Union[NetworkService, Feeder], network_state_op 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)]) + else: + raise NotImplementedError - async def find_all(self, network_service: NetworkService, network_state_operators: NetworkStateOperators=NetworkStateOperators.NORMAL) -> AsyncGenerator[ConductingEquipment, None]: + 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. @@ -59,7 +65,7 @@ async def find_all(self, network_service: NetworkService, network_state_operator 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. @@ -79,18 +85,20 @@ 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 + ).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): @@ -107,7 +115,7 @@ def condition(next_step, nctx, step, ctx): 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: 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..20d4a0dbf 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 @@ -37,7 +37,7 @@ class EquipmentTreeBuilder(StepActionWithContextValue): _roots: dict[ConductingEquipment, EquipmentTreeNode]={} def __init__(self): - self.key = str(uuid.uuid4()) + super().__init__(_func=self.apply, key=str(uuid.uuid4())) @property def roots(self) -> Generator[TreeNode[ConductingEquipment], None, None]: @@ -50,7 +50,7 @@ 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: 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..d4e93e259 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 @@ -7,7 +7,7 @@ from typing import Generic, TypeVar, TYPE_CHECKING, Type from zepben.evolve.services.network.tracing.traversal.stop_condition import StopConditionWithContextValue -from zepben.evolve.services.network.tracing.traversal.context_value_computer import TypedContextValueComputer +from zepben.evolve.services.network.tracing.traversal.context_value_computer import ContextValueComputer 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..9c65caab3 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py @@ -5,6 +5,7 @@ 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 @@ -74,25 +75,32 @@ def __init__(self, network_state_operators: Type[NetworkStateOperators], queue_type: Union[Traversal.BasicQueueType, Traversal.BranchingQueueType], parent: 'NetworkTrace[T]'=None, - action_type: NetworkTraceActionType=None + action_type: NetworkTraceActionType=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]] + name: str, + compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]], + debug_logger=None ) -> 'NetworkTrace[T]': return cls(network_state_operators, Traversal.BasicQueueType(NetworkTraceQueueNext.Basic( @@ -100,7 +108,9 @@ def non_branching(cls, compute_data_with_action_type(compute_data, action_type) ), queue), None, - action_type) + action_type, + debug_logger, + name) @classmethod def branching(cls, @@ -108,8 +118,10 @@ def branching(cls, queue_factory: Callable[[], TraversalQueue[T]], branch_queue_factory: Callable[[], TraversalQueue['NetworkTrace[T]']], action_type: NetworkTraceActionType, + 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, @@ -118,7 +130,9 @@ def branching(cls, compute_data_with_action_type(compute_data, action_type) ), queue_factory, branch_queue_factory), parent, - action_type) + action_type, + debug_logger, + name) @singledispatchmethod def add_start_item(self, start: Union[Terminal, ConductingEquipment], data: T=None, phases: PhaseCode=None) -> "NetworkTrace[T]": @@ -289,7 +303,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]: 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..8b595dcaf 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 @@ -18,6 +18,9 @@ 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. @@ -116,3 +119,10 @@ def type(self) -> Type: 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..aeeec751f 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 @@ -47,6 +47,8 @@ class NetworkStateOperators(OpenStateOperators, or creating redundant trace implementations for different network states. """ + description: str + @classproperty def NORMAL(cls) -> Type['NormalNetworkStateOperators']: return NormalNetworkStateOperators @@ -71,6 +73,8 @@ class NormalNetworkStateOperators(NetworkStateOperators, Instance that operates on the normal state of network objects. """ + description = 'normal' + CURRENT = False NORMAL = True @@ -93,6 +97,8 @@ class CurrentNetworkStateOperators(NetworkStateOperators, Instance that operates on the current state of network objects. """ + description = 'current' + CURRENT = True NORMAL = False diff --git a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py index 1dd794dd3..b6953758c 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py @@ -2,6 +2,7 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +from logging import Logger from typing import TypeVar, Union, Callable, Type from zepben.evolve.services.network.tracing.networktrace.compute_data import ComputeData, ComputeDataWithPaths @@ -18,6 +19,8 @@ class Tracing: @staticmethod def network_trace(network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, action_step_type: NetworkTraceActionType=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]: @@ -27,6 +30,8 @@ def network_trace(network_state_operators: Type[NetworkStateOperators]=NetworkSt :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 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` @@ -34,11 +39,18 @@ def network_trace(network_state_operators: Type[NetworkStateOperators]=NetworkSt 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, + 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 @@ -47,44 +59,51 @@ def network_trace_branching(network_state_operators: Type[NetworkStateOperators] 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..0cb8e4a22 100644 --- a/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py +++ b/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py @@ -1,16 +1,20 @@ -# 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 +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): @@ -43,14 +50,15 @@ async def run(self, network: NetworkService, network_state_operators: Type[Netwo """ 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: @@ -184,7 +192,7 @@ async def _infer_xy_phases(self, terminal: Terminal, max_missing_phases: int, tr async def _continue_phases(self, terminal: Terminal): - set_phases_trace = Tracing.set_phases() + 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) 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..0b5926473 100644 --- a/src/zepben/evolve/services/network/tracing/phases/remove_phases.py +++ b/src/zepben/evolve/services/network/tracing/phases/remove_phases.py @@ -1,11 +1,11 @@ -# 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 @@ -16,9 +16,13 @@ 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.networktrace.conditions.conditions import stop_at_open 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,24 @@ class RemovePhases(object): This class is backed by a `BranchRecursiveTraversal`. """ + 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 +65,36 @@ 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): + """ + 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): + """ + 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) @@ -83,9 +120,11 @@ def queue_condition(next_step: NetworkTraceStep, next_ctx: StepContext=None, ste 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_condition(stop_at_open()) \ .add_step_action(step_action) \ .add_queue_condition(queue_condition) 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..c4b9fe664 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,7 +6,7 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Union, Set, Iterable, List, Type +from typing import Union, Set, Iterable, List, Type, TYPE_CHECKING from zepben.evolve.exceptions import TracingException, PhaseException from zepben.evolve.model.cim.iec61970.base.core.phase_code import PhaseCode @@ -24,6 +24,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,11 +36,17 @@ 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], @@ -62,7 +71,7 @@ async def _run(self, """ Apply phases from all sources in the network. - @param network: The network in which to apply phases. + :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): @@ -71,14 +80,14 @@ async def _run(self, 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): + terminal: Terminal, + phases: Union[PhaseCode, Iterable[SinglePhaseKind]], + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): """ Apply phases from the `terminal`. - @param terminal: The terminal to start applying phases from. - @param phases: The phases to apply. Must only contain ABCN. + :param terminal: The terminal to start applying phases from. + :param phases: The phases to apply. Must only contain ABCN. """ def validate_phases(_phases): if len(_phases) != len(terminal.phases.single_phases): @@ -141,27 +150,24 @@ async def _run_terminal(self, terminal: Terminal, network_state_operators: Type[ 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 + 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) ) - 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 - 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), compute_data=self._compute_next_phases_to_flow(state_operators) ) - .add_queue_condition(condition) + .add_queue_condition( + lambda next_step, *args: len(next_step.data.nominal_phase_paths) > 0 + ) .add_step_action(step_action) ) 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..31bef5a3b 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,7 +11,7 @@ T = TypeVar('T') U = TypeVar('U') -__all__ = ['ContextValueComputer', 'TypedContextValueComputer'] +__all__ = ['ContextValueComputer'] class ContextValueComputer(Generic[T]): @@ -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. + :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. """ - pass + raise NotImplemented - 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: - """ - Computes the next context value of type [U] based on the current item, next item, and the current context value. - - `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..78ca2f3fe --- /dev/null +++ b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py @@ -0,0 +1,47 @@ +# 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 functools +from logging import Logger +from types import FunctionType +from typing import TypeVar, Union + +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 +from zepben.evolve.services.network.tracing.traversal.traversal_condition import TraversalCondition + +T = TypeVar('T') + + +class DebugLoggingWrapper: + + def __init__(self, description: str, logger: Logger): + self.description: str = description + self._logger: Logger = logger + + def wrap(self, obj: Union[StepAction[T], TraversalCondition[T]], count: int=None): + def wrapattr(attr, msg) -> None: + setattr(obj, attr, self._log_method_call(getattr(obj, attr), msg)) + + if isinstance(obj, StepAction): + wrapattr('apply', f'{self.description}: stepping_on({count})' + ' [item={args[0]}, context={args[1]}]') + elif isinstance(obj, StopCondition): + wrapattr('should_stop', f'{self.description}: should_stop({count})' + '={result} [item={args[0]}, context={args[1]}]') + elif isinstance(obj, QueueCondition): + wrapattr('should_queue', f'{self.description}: should_queue({count})' + ( + '={result} [next_item={args[0]}, next_context={args[1]}, current_item={args[2]}, current_context={args[3]}]')) + wrapattr('should_queue_start_item', f'{self.description}: should_queue({count})' + '={result} [item={args[0]}]') + else: + raise AttributeError(f'{type(self)} does not support wrapping {obj}') + return obj + + def _log_method_call(self, func: FunctionType, log_string: str): + def wrapper(*args, **kwargs): + result = func(*args, **kwargs) + msg = f"{self._logger.name}: {log_string.format(result=result, args=list(map(bool, (args))))}" + self._logger.debug(msg) + print(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..49d66b834 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/queue_condition.py +++ b/src/zepben/evolve/services/network/tracing/traversal/queue_condition.py @@ -39,7 +39,7 @@ def should_queue(self, next_item: T, next_context: StepContext, current_item: T, `currentContext` The context associated with the [currentItem]. Returns `true` if the [nextItem] should be queued; `false` otherwise. """ - raise NotImplementedError + raise NotImplemented @staticmethod def should_queue_start_item(item: T) -> bool: @@ -52,12 +52,20 @@ def should_queue_start_item(item: T) -> bool: 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. `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..dcf6a808e 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 -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') @@ -30,16 +30,26 @@ def apply(self, item: T, context: StepContext): """ 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]): +class StepActionWithContextValue(StepAction[T], ContextValueComputer[T]): """ Interface representing a step action that utilises a value stored in the [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, _func: StepActionFunc, key: str): + StepAction.__init__(self, _func) + ContextValueComputer.__init__(self, key) + + @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_context.py b/src/zepben/evolve/services/network/tracing/traversal/step_context.py index 65e72b3d5..df5c1c9b9 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/step_context.py +++ b/src/zepben/evolve/services/network/tracing/traversal/step_context.py @@ -49,4 +49,7 @@ 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..d2ef6c21d 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 @@ -38,10 +38,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. """ + + @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..274e9a7fc 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/traversal.py +++ b/src/zepben/evolve/services/network/tracing/traversal/traversal.py @@ -9,10 +9,14 @@ 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 typing_extensions import Required + 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 @@ -35,27 +39,36 @@ 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]. + - T: The type of object to be traversed. + - D: The specific type of traversal, extending [Traversal]. """ class QueueType(Generic[T, D]): """ 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: TraversalQueue[T] @property + @abstractmethod def queue(self) -> TraversalQueue[T]: raise NotImplementedError @@ -64,12 +77,11 @@ def branch_queue(self) -> Optional[TraversalQueue[D]]: raise NotImplementedError - class BasicQueueType(QueueType[T, D], Generic[T, D]): + class BasicQueueType(QueueType[T, D]): """ 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. + :var queue_next: Logic for queueing the next item in the traversal. """ def __init__(self, queue_next: Traversal.QueueNext[T], queue: TraversalQueue[T]): self.queue_next = queue_next @@ -78,6 +90,9 @@ def __init__(self, queue_next: Traversal.QueueNext[T], queue: TraversalQueue[T]) @property def queue(self) -> TraversalQueue[T]: + """ + The primary queue of items. + """ return self._queue @property @@ -85,9 +100,10 @@ def branch_queue(self) -> Optional[TraversalQueue[D]]: return self._branch_queue - class BranchingQueueType(QueueType[T, D], Generic[T, D]): + class BranchingQueueType(QueueType[T, D]): """ - 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. @@ -109,19 +125,24 @@ def queue(self) -> TraversalQueue[T]: def branch_queue(self) -> Optional[TraversalQueue[D]]: return self.branch_queue_factory() - _queue_type: Union[BasicQueueType, BranchingQueueType] + name: str + """ + The name of the traversal. Can be used for logging purposes and will + be included in all debug logging. + """ - 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: + 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 @@ -133,6 +154,15 @@ def __init__(self, queue_type, parent: Optional[D] = None): self.compute_next_context_funs: Dict[str, ContextValueComputer[T]] = {} self.contexts: Dict[T, StepContext] = {} + def with_logger(self, logger: Logger) -> D: + """ + Method to set the debug_logger after Traversal.__init__() has ran + :param logger: the logger to use + :return: self + """ + self._debug_logger = DebugLoggingWrapper(self.name, logger) + return self + def queue_next(self, current_item: T, context: StepContext): raise NotImplementedError @@ -148,7 +178,8 @@ 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. @@ -163,6 +194,11 @@ def create_new_this(self) -> D: """ Creates a new instance of the traversal for branching purposes. + 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 @@ -192,8 +228,9 @@ def add_condition(self, condition: ConditionTypes) -> D: @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. @@ -206,6 +243,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 @@ -232,7 +273,8 @@ 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. @@ -246,6 +288,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 @@ -264,12 +310,17 @@ def copy_queue_conditions(self, other: Traversal[T, D]) -> D: def add_step_action(self, action: Union[StepActionFunc, StepAction[T]]) -> D: """ - Adds an action to be performed on each item in the traversal, including the starting items. + Adds an action to be performed on each item in the traversal, including the + starting items. `action` The action to perform on each item. Returns The current traversal instance. """ if isinstance(action, StepAction): + + if self._debug_logger is not None: + self._debug_logger.wrap(action) + self.step_actions.append(action) if isinstance(action, StepActionWithContextValue): self.compute_next_context_funs[action.key] = action @@ -287,7 +338,17 @@ def if_not_stopping(self, action: Callable[[T, StepContext], None]) -> D: `action` The action to perform on each non-stopping item. Returns The current traversal instance. """ - self.step_actions.append(StepAction(lambda it, context: action(it, context) if not context.is_stopping else None)) + # TODO: at the moment were assuming a function being passed in, so we can turn it into + # a step action here, this prevents StepActionWithContextValue being passed in, however + # in future we want to allow passing step actions in here. the JVMSDK throws an error + # if you pass context aware step actions into here, though why cant we just send this + # on to `add_step_action`... + step_action = StepAction(lambda it, context: action(it, context) if not context.is_stopping else None) + + if self._debug_logger is not None: + self._debug_logger.wrap(step_action) + + self.step_actions.append(step_action) return self @@ -298,7 +359,17 @@ def if_stopping(self, action: Callable[[T, StepContext], None]) -> D: `action` The action to perform on each stopping item. Returns The current traversal instance. """ - self.step_actions.append(StepAction(lambda it, context: action(it, context) if context.is_stopping else None)) + # TODO: at the moment were assuming a function being passed in, so we can turn it into + # a step action here, this prevents StepActionWithContextValue being passed in, however + # in future we want to allow passing step actions in here. the JVMSDK throws an error + # if you pass context aware step actions into here, though why cant we just send this + # on to `add_step_action`... + step_action = StepAction(lambda it, context: action(it, context) if context.is_stopping else None) + + if self._debug_logger is not None: + self._debug_logger.wrap(step_action) + + self.step_actions.append(step_action) return self def copy_step_actions(self, other: Traversal[T, D]) -> D: @@ -322,7 +393,8 @@ 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. @@ -333,8 +405,10 @@ def add_context_value_computer(self, computer: ContextValueComputer[T]) -> D: 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. @@ -362,8 +436,8 @@ 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 @@ -373,9 +447,10 @@ 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,7 +480,7 @@ 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 @@ -420,7 +495,8 @@ 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() diff --git a/src/zepben/evolve/testing/test_network_builder.py b/src/zepben/evolve/testing/test_network_builder.py index 7c327c373..c3f966b48 100644 --- a/src/zepben/evolve/testing/test_network_builder.py +++ b/src/zepben/evolve/testing/test_network_builder.py @@ -2,14 +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 logging import Logger 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 +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 +368,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`. @@ -483,7 +484,7 @@ def with_cut( is_normally_open: bool = True, is_open: bool = None, 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. @@ -625,10 +626,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 +637,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 +807,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/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..060dfd921 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 @@ -34,7 +34,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..6dcb7aa92 100644 --- a/test/services/network/tracing/networktrace/test_network_trace.py +++ b/test/services/network/tracing/networktrace/test_network_trace.py @@ -156,7 +156,7 @@ async def test_can_stop_on_start_item_when_running_from_conducting_equipment(sel == [(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 diff --git a/test/services/network/tracing/traversal/test_traversal.py b/test/services/network/tracing/traversal/test_traversal.py index d2adcf1fa..67dea8f61 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 T = TypeVar('T') D = TypeVar('D') class TraversalTest(Traversal[T, D]): - def __init__(self, queue_type, parent: Optional["TraversalTest[T]"], + + 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]): - super().__init__(queue_type, parent) + 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,7 +40,7 @@ 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) @@ -376,3 +384,12 @@ async def test_multiple_start_items_respect_can_stop_on_start(self): await traversal.run(can_stop_on_start_item=False) assert steps == [1, 11, 2, 12] + + @pytest.mark.asyncio + async def test_must_use_add_step_action_for_context_aware_actions(self): + action = StepActionWithContextValue[int](lambda step, ctx: None, key='123') + _create_traversal().add_step_action(action) + + _create_traversal().if_stopping(action) + + _create_traversal().if_not_stopping(action) From 5e9819ec5d4dbb5b2c6c9f182413f834b9c8c43b Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Thu, 29 May 2025 17:07:09 +1000 Subject: [PATCH 02/28] reworked the logger slightly, added heaps of docs to it Signed-off-by: Max Chesterfield --- .../tracing/traversal/debug_logging.py | 88 ++++++++++--- .../traversal/test_debug_logging_wrapper.py | 120 ++++++++++++++++++ 2 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 test/services/network/tracing/traversal/test_debug_logging_wrapper.py diff --git a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py index 78ca2f3fe..d30786fd4 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py +++ b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py @@ -2,46 +2,104 @@ # 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 functools from logging import Logger from types import FunctionType -from typing import TypeVar, Union +from typing import TypeVar, Union, cast, Optional, Type 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 -from zepben.evolve.services.network.tracing.traversal.traversal_condition import TraversalCondition T = TypeVar('T') +Wrappable = Union[StepAction[T], QueueCondition[T], StopCondition[T]] + class DebugLoggingWrapper: + _wrapped = { + StepAction: [], + StopCondition: [], + QueueCondition: [] + } def __init__(self, description: str, logger: Logger): self.description: str = description self._logger: Logger = logger - def wrap(self, obj: Union[StepAction[T], TraversalCondition[T]], count: int=None): - def wrapattr(attr, msg) -> None: + def wrap(self, obj: Wrappable, count: Optional[int]=None): + """ + Wrap, in place, supported methods of the object passed in. + + Supported object.methods: + + - StepAction.action + - StopCondition.should_stop + - QueueCondition + + - should_queue + - should_queue_start_item + + :param obj: instantiated object representing a condition or action in a `Traversal` + :param count: (optional) set the `count` in the log message + :return: the object passed in for fluent use + """ + + def wrapobj(_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].append(obj) + if count is not None: + return count + return len(self._wrapped[clazz]) + + def wrapattr(attr: str, msg: str) -> None: + """ + Replaces the specified attr with a wrapper around the same attr to inject + logging. + + :param attr: Method/Function name. + :param msg: Log message format string to output when `attr` is called. + args/kwargs passed to the function are passed to `str.format()`, + as is `result` which is the result of the function itself + """ setattr(obj, attr, self._log_method_call(getattr(obj, attr), msg)) - if isinstance(obj, StepAction): - wrapattr('apply', f'{self.description}: stepping_on({count})' + ' [item={args[0]}, context={args[1]}]') - elif isinstance(obj, StopCondition): - wrapattr('should_stop', f'{self.description}: should_stop({count})' + '={result} [item={args[0]}, context={args[1]}]') - elif isinstance(obj, QueueCondition): - wrapattr('should_queue', f'{self.description}: should_queue({count})' + ( + # FIXME: when we drop 3.9 support, this can be replaced with a match case statement based + # on the below one-liner, and multiple calls to _count can be dropped as we will know the + # class before hitting any of the case blocks. + # _subtype = [t for t in (StepAction, StopCondition, QueueCondition) if t in type(obj).mro()].pop() or None + if isinstance(obj, clazz := StepAction): + _count = wrapobj(clazz) + wrapattr('apply', f'{self.description}: stepping_on({_count})' + ' [item={args[0]}, context={args[1]}]') + + elif isinstance(obj, clazz := StopCondition): + _count = wrapobj(clazz) + wrapattr('should_stop', f'{self.description}: should_stop({_count})' + '={result} [item={args[0]}, context={args[1]}]') + + elif isinstance(obj, clazz := QueueCondition): + _count = wrapobj(clazz) + wrapattr('should_queue', f'{self.description}: should_queue({_count})' + ( '={result} [next_item={args[0]}, next_context={args[1]}, current_item={args[2]}, current_context={args[3]}]')) - wrapattr('should_queue_start_item', f'{self.description}: should_queue({count})' + '={result} [item={args[0]}]') + wrapattr('should_queue_start_item', f'{self.description}: should_queue_start_item({_count})' + '={result} [item={args[0]}]') + else: raise AttributeError(f'{type(self)} does not support wrapping {obj}') - return obj + # This cast is for type hints, without it, any object returned is treated as a combination of all types accepted as `obj` + return cast(clazz, 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: any string supported by `str.format()` + """ def wrapper(*args, **kwargs): result = func(*args, **kwargs) - msg = f"{self._logger.name}: {log_string.format(result=result, args=list(map(bool, (args))))}" + msg = f"{self._logger.name}: {log_string.format(result=result, args=args, kwargs=kwargs)}" self._logger.debug(msg) - print(msg) return result return wrapper 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..e9ba137c5 --- /dev/null +++ b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py @@ -0,0 +1,120 @@ +# 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, count=None): + return DebugLoggingWrapper('my desc', self.logger).wrap(condition, count) + + 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, 100) + assert wrapped is stop_condition + assert isinstance(wrapped, StopCondition) + assert not isinstance(wrapped, QueueCondition) + assert not isinstance(wrapped, StepAction) + + queue_condition = QueueCondition(lambda nitem, nctx, item, ctx: next(should_stop)) + wrapped = self._wrap(queue_condition, 20) + assert wrapped is queue_condition + assert not isinstance(wrapped, StopCondition) + assert isinstance(wrapped, QueueCondition) + assert not isinstance(wrapped, StepAction) + + action = StepAction(lambda item, context: None) + wrapped = self._wrap(action, 20) + assert wrapped is action + assert not isinstance(wrapped, StopCondition) + assert not isinstance(wrapped, 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)), 100) + + 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(100)=True [item={self.item_1}, context={self.context_1}]" + assert handler.log_list.get() == f"root: my desc: should_stop(100)=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) + self._wrap(condition, 50) + + 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(50)=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(50)=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(50)=False [item={self.item_1}]" + assert handler.log_list.get() == f"root: my desc: should_queue_start_item(50)=True [item={self.item_2}]" + + def test_can_wrap_step_actions(self): + action = self._wrap(StepAction(lambda item, context: None), 1) + + 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: stepping_on(1) [item={self.item_1}, context={self.context_1}]" + assert handler.log_list.get() == f"root: my desc: stepping_on(1) [item={self.item_2}, context={self.context_2}]" From 1605456df55fa19a3f14b28aa656105da0e53aa9 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Thu, 29 May 2025 20:40:25 +1000 Subject: [PATCH 03/28] Minor formatting fix Signed-off-by: Max Chesterfield --- .../network/tracing/networktrace/tracing.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py index b6953758c..cfb8c6738 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py @@ -49,8 +49,8 @@ def network_trace(network_state_operators: Type[NetworkStateOperators]=NetworkSt @staticmethod def network_trace_branching(network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, action_step_type: NetworkTraceActionType=NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, - debug_logger: Logger = None, - name: str = 'NetworkTrace', + 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 @@ -69,41 +69,41 @@ def network_trace_branching(network_state_operators: Type[NetworkStateOperators] debug_logger=debug_logger) @staticmethod - def set_direction(debug_logger: Logger = None): + def set_direction(debug_logger: Logger=None): from zepben.evolve.services.network.tracing.feeder.set_direction import SetDirection return SetDirection(debug_logger=debug_logger) @staticmethod - def clear_direction(debug_logger: Logger = None): + def clear_direction(debug_logger: Logger=None): from zepben.evolve.services.network.tracing.feeder.clear_direction import ClearDirection return ClearDirection(debug_logger=debug_logger) @staticmethod - def assign_equipment_to_feeders(debug_logger: Logger = None): + def assign_equipment_to_feeders(debug_logger: Logger=None): from zepben.evolve.services.network.tracing.feeder.assign_to_feeders import AssignToFeeders return AssignToFeeders(debug_logger=debug_logger) @staticmethod - def assign_equipment_to_lv_feeders(debug_logger: Logger = None): + 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(debug_logger=debug_logger) @staticmethod - def set_phases(debug_logger: Logger = None): + def set_phases(debug_logger: Logger=None): from zepben.evolve.services.network.tracing.phases.set_phases import SetPhases return SetPhases(debug_logger=debug_logger) @staticmethod - def remove_phases(debug_logger: Logger = None): + def remove_phases(debug_logger: Logger=None): from zepben.evolve.services.network.tracing.phases.remove_phases import RemovePhases return RemovePhases(debug_logger=debug_logger) @staticmethod - def phase_inferrer(debug_logger: Logger = None): + def phase_inferrer(debug_logger: Logger=None): from zepben.evolve.services.network.tracing.phases.phase_inferrer import PhaseInferrer return PhaseInferrer(debug_logger=debug_logger) @staticmethod - def find_swer_equipment(debug_logger: Logger = None): + def find_swer_equipment(debug_logger: Logger=None): from zepben.evolve.services.network.tracing.find_swer_equipment import FindSwerEquipment return FindSwerEquipment(debug_logger=debug_logger) From acf03df40f81bbb9c5746f1065e93afc0402c481 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Wed, 4 Jun 2025 13:42:48 +1000 Subject: [PATCH 04/28] update changelog Signed-off-by: Max Chesterfield --- changelog.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/changelog.md b/changelog.md index b91960d8e..cd6933fa4 100644 --- a/changelog.md +++ b/changelog.md @@ -9,6 +9,17 @@ 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 `debugLogging` and `name` parameters to the constructor of the following traces. The helper functions in `Tracing` also have these parameters, + which defaults to `null` and `networkTrace`, 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. ### New Features * Added `ClearDirection` that clears feeder directions. From 6f665854b1f3dbf0293b544b22c9acb780b04a17 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Wed, 4 Jun 2025 15:37:23 +1000 Subject: [PATCH 05/28] Moving docstrings to where they should be, de-duping code Signed-off-by: Max Chesterfield --- .../tracing/feeder/assign_to_feeders.py | 17 ++++---- .../tracing/feeder/assign_to_lv_feeders.py | 41 +++++++++---------- .../network/tracing/feeder/clear_direction.py | 9 ++-- .../network/tracing/networktrace/tracing.py | 4 +- .../networktrace/test_network_trace.py | 3 +- 5 files changed, 33 insertions(+), 41 deletions(-) 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 21eb5ba43..c7aff9b7d 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 @@ -27,15 +27,15 @@ class AssignToFeeders: - def __init__(self, debug_logger: Logger = None): - self._debug_logger = debug_logger - """ Convenience class that provides methods for assigning HV/MV feeders on a `NetworkService`. Requires that a Feeder have a normalHeadTerminal with associated ConductingEquipment. This class is backed by a `NetworkTrace`. """ + 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, @@ -49,6 +49,7 @@ async def run(self, * 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, self._debug_logger @@ -86,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: @@ -134,9 +134,7 @@ 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) @@ -146,7 +144,8 @@ 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]: + 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 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 5d6804eef..9e0e419c9 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 @@ -27,6 +27,12 @@ class AssignToLvFeeders: + """ + 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`. + """ + def __init__(self, debug_logger: Logger=None): self._debug_logger = debug_logger @@ -35,6 +41,14 @@ 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 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 @@ -48,6 +62,7 @@ async def _(self, lv_feeders_to_assign: List[LvFeeder], network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL ): + await AssignToLvFeedersInternal( network_state_operators, self._debug_logger @@ -59,21 +74,10 @@ async def _(self, ) 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): - """ - 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` - """ lv_feeder_start_points = network.lv_feeder_start_points terminal_to_aux_equipment = network.aux_equipment_by_terminal @@ -111,9 +115,7 @@ async def run_with_feeders(self, 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) @@ -125,10 +127,7 @@ def _create_trace(self, lv_feeders_to_assign: List[LvFeeder]) -> NetworkTrace[T]: def _reached_hv(ce: ConductingEquipment): - if ce.base_voltage and ce.base_voltage.nominal_voltage >= 1000: - return True - else: - return False + return True if ce.base_voltage and ce.base_voltage.nominal_voltage >= 1000 else False 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) @@ -162,9 +161,8 @@ async def _process(self, # 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) @@ -184,8 +182,7 @@ async def _process(self, 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 aed14e2b3..2b997ac25 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py +++ b/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py @@ -22,10 +22,9 @@ __all__ = ['ClearDirection'] -__all__ =['ClearDirection'] - 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 @@ -47,11 +46,9 @@ 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 diff --git a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py index cfb8c6738..151285e6a 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py @@ -7,7 +7,7 @@ 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 @@ -18,7 +18,7 @@ class Tracing: @staticmethod def network_trace(network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, - action_step_type: NetworkTraceActionType=NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, + action_step_type: CanActionItem=NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, debug_logger: Logger=None, name: str='NetworkTrace', queue: TraversalQueue[NetworkTraceStep[T]]=TraversalQueue.depth_first(), diff --git a/test/services/network/tracing/networktrace/test_network_trace.py b/test/services/network/tracing/networktrace/test_network_trace.py index 6dcb7aa92..741ac309d 100644 --- a/test/services/network/tracing/networktrace/test_network_trace.py +++ b/test/services/network/tracing/networktrace/test_network_trace.py @@ -11,8 +11,7 @@ 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__ From c3a9e8df3b43e1a96d37ccdeeb19a4b83b7dd62d Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Wed, 4 Jun 2025 15:50:54 +1000 Subject: [PATCH 06/28] Revert change not related to branch for later commit Signed-off-by: Max Chesterfield --- .../evolve/services/network/tracing/networktrace/tracing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py index 151285e6a..cfb8c6738 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py @@ -7,7 +7,7 @@ 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, CanActionItem +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.traversal.queue import TraversalQueue @@ -18,7 +18,7 @@ class Tracing: @staticmethod def network_trace(network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, - action_step_type: CanActionItem=NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, + action_step_type: NetworkTraceActionType=NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, debug_logger: Logger=None, name: str='NetworkTrace', queue: TraversalQueue[NetworkTraceStep[T]]=TraversalQueue.depth_first(), From 5bf66ab4563bc369191d79d955758fde64fc6fc7 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Wed, 4 Jun 2025 17:44:52 +1000 Subject: [PATCH 07/28] updated copypasta changelog to contain the actual var/object names Signed-off-by: Max Chesterfield --- changelog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.md b/changelog.md index cd6933fa4..53ff79009 100644 --- a/changelog.md +++ b/changelog.md @@ -9,8 +9,8 @@ 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 `debugLogging` and `name` parameters to the constructor of the following traces. The helper functions in `Tracing` also have these parameters, - which defaults to `null` and `networkTrace`, meaning anyone using these wrappers will be unaffected by the change: +* 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` From 8746e903038723120e4a02bd4f5a8a8ffd57021a Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Wed, 4 Jun 2025 20:32:58 +1000 Subject: [PATCH 08/28] commitlog bro. Signed-off-by: Max Chesterfield --- changelog.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/changelog.md b/changelog.md index 53ff79009..4c2f4003f 100644 --- a/changelog.md +++ b/changelog.md @@ -23,6 +23,8 @@ ### 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. From 3ad58b083c22d54d0f54966d2d43a96915a74a69 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Wed, 4 Jun 2025 17:54:26 +1000 Subject: [PATCH 09/28] fix docstring formatting, code layout, and general messy code caused by me Signed-off-by: Max Chesterfield --- src/zepben/evolve/__init__.py | 3 - .../network/tracing/feeder/clear_direction.py | 27 +++-- .../network/tracing/feeder/set_direction.py | 32 +++--- .../network/tracing/find_swer_equipment.py | 19 ++- .../actions/equipment_tree_builder.py | 8 +- .../tracing/networktrace/network_trace.py | 108 +++++++++++------- .../networktrace/network_trace_step.py | 24 ++-- .../operators/network_state_operators.py | 2 +- .../network/tracing/networktrace/tracing.py | 51 ++++++--- .../network/tracing/phases/phase_inferrer.py | 4 +- .../network/tracing/phases/remove_phases.py | 21 ++-- .../network/tracing/phases/set_phases.py | 70 +++++++----- .../traversal/context_value_computer.py | 4 +- .../tracing/traversal/debug_logging.py | 5 + .../tracing/traversal/queue_condition.py | 22 ++-- .../network/tracing/traversal/step_action.py | 7 +- .../network/tracing/traversal/step_context.py | 19 +-- .../tracing/traversal/stop_condition.py | 7 +- .../network/tracing/traversal/traversal.py | 98 +++++++++------- 19 files changed, 316 insertions(+), 215 deletions(-) diff --git a/src/zepben/evolve/__init__.py b/src/zepben/evolve/__init__.py index f64d2b6f5..1e6c52492 100644 --- a/src/zepben/evolve/__init__.py +++ b/src/zepben/evolve/__init__.py @@ -156,7 +156,6 @@ from zepben.evolve.services.network.translator.network_cim2proto import * from zepben.evolve.services.network.network_service import * -from zepben.evolve.services.network.tracing.traversal.step_context 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 * @@ -452,8 +451,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/services/network/tracing/feeder/clear_direction.py b/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py index 2b997ac25..bafb41c76 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py +++ b/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py @@ -29,15 +29,16 @@ class ClearDirection: 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 @@ -48,14 +49,16 @@ async def run(self, :param network_state_operators: The `NetworkStateOperators` to be used when removing directions. :return : A set of feeder head `Terminal`s encountered when clearing directions """ + trace = self._create_trace(network_state_operators, feeder_head_terminals := []) await trace.run(terminal, can_stop_on_start_item=False) return feeder_head_terminals - def _create_trace(self, - state_operators: Type[NetworkStateOperators], - visited_feeder_head_terminals: list[Terminal] - ) -> NetworkTrace[Any]: + 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) 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 c0ac1d26d..f5e3d2b23 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/set_direction.py +++ b/src/zepben/evolve/services/network/tracing/feeder/set_direction.py @@ -33,14 +33,17 @@ class SetDirection: Convenience class that provides methods for setting feeder direction on a [NetworkService] 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 @@ -59,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 @@ -79,7 +81,7 @@ 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] = [] return ( @@ -105,11 +107,9 @@ async def _create_traversal(self, state_operators: Type[NetworkStateOperators]) @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]): @@ -123,10 +123,9 @@ async def run(self, network: NetworkService, network_state_operators: Type[Netwo :param network: The network in which to apply feeder directions. :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) @@ -138,6 +137,7 @@ async def run_terminal(self, terminal: Terminal, network_state_operators: Type[N :param terminal: The terminal to start applying feeder direction from. :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 76652fb19..4965b8df7 100644 --- a/src/zepben/evolve/services/network/tracing/find_swer_equipment.py +++ b/src/zepben/evolve/services/network/tracing/find_swer_equipment.py @@ -44,6 +44,7 @@ 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): @@ -61,6 +62,7 @@ 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 @@ -74,10 +76,11 @@ async def find_on_feeder(self, feeder: Feeder, network_state_operators: Type[Net :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): @@ -98,7 +101,12 @@ async def _trace_from(self, state_operators: Type[NetworkStateOperators], transf # Trace from any LV terminals. await self._trace_lv_from(state_operators, transformer, swer_equipment) - async def _trace_swer_from(self, state_operators: Type[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): @@ -115,7 +123,12 @@ def condition(next_step, nctx, step, ctx): await trace.run(it, None) - async def _trace_lv_from(self, state_operators: Type[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: 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 20d4a0dbf..a18ebd8e7 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 @@ -50,7 +50,13 @@ def compute_initial_value(self, item: NetworkTraceStep[Any]) -> EquipmentTreeNod self._roots[item.path.to_equipment] = node return node - def compute_next_value(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: 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 9c65caab3..443b155c5 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py @@ -16,7 +16,7 @@ from zepben.evolve.model.cim.iec61970.base.wires.single_phase_kind import SinglePhaseKind from zepben.evolve.services.network.tracing.networktrace.compute_data import ComputeData, ComputeDataWithPaths -from zepben.evolve.services.network.tracing.networktrace.network_trace_action_type import NetworkTraceActionType +from zepben.evolve.services.network.tracing.networktrace.network_trace_action_type import NetworkTraceActionType, CanActionItem from zepben.evolve.services.network.tracing.networktrace.conditions.network_trace_stop_condition import NetworkTraceStopCondition, ShouldStop from zepben.evolve.services.network.tracing.networktrace.conditions.network_trace_queue_condition import NetworkTraceQueueCondition from zepben.evolve.services.network.tracing.networktrace.network_trace_queue_next import NetworkTraceQueueNext @@ -32,34 +32,36 @@ 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. 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 `BusbarSection`s and `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 `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. 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.conductingEquipment`. + - **Internal Step**: Moves between terminals within the same `Terminal.conductingEquipment`. - 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 `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 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 `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' @@ -68,17 +70,19 @@ 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 `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, - debug_logger: Logger=None, - name: str=None - ): + def __init__( + self, + network_state_operators: Type[NetworkStateOperators], + queue_type: Union[Traversal.BasicQueueType, Traversal.BranchingQueueType], + parent: 'NetworkTrace[T]'=None, + action_type: NetworkTraceActionType=None, + debug_logger: Logger=None, + name: str=None + ): + if name is None: raise ValueError('name can not be None') self.name = name @@ -94,14 +98,16 @@ def __init__(self, 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, - name: str, - compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]], - debug_logger=None - ) -> 'NetworkTrace[T]': + 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, @@ -113,16 +119,17 @@ def non_branching(cls, 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, - name: str, - parent: 'NetworkTrace[T]'=None, - compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]]=None, - debug_logger: Logger=None, - ) -> 'NetworkTrace[T]': + 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( @@ -147,6 +154,7 @@ def add_start_item(self, start: Union[Terminal, ConductingEquipment], data: T=No :param data: The data associated with the start step. :param phases: Phases to trace; `None` to ignore phases. """ + raise Exception('INTERNAL ERROR:: unexpected add_start_item params') @add_start_item.register @@ -187,7 +195,6 @@ def start_terminals() -> Generator[Terminal, None, None]: for terminal in start_terminals(): self._add_start_item(terminal, data, phases, start) - def _add_start_item(self, start: Terminal=None, data: T=None, @@ -199,7 +206,13 @@ def _add_start_item(self, 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]=None, + data: T=None, + phases: PhaseCode=None, + can_stop_on_start_item: bool=True + ) -> "NetworkTrace[T]": """ Runs the network trace starting from `start` @@ -212,6 +225,7 @@ async def run(self, start: Union[ConductingEquipment, Terminal]=None, data: T=No :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) @@ -220,7 +234,6 @@ async def run(self, start: Union[ConductingEquipment, Terminal]=None, data: T=No @singledispatchmethod def add_condition(self, condition: QueueCondition[T]) -> "NetworkTrace[T]": - """ Adds a traversal condition to the trace. @@ -232,6 +245,7 @@ def add_condition(self, condition: QueueCondition[T]) -> "NetworkTrace[T]": :param condition: The condition to be added :returns: This `NetworkTrace` instance """ + return super().add_condition(condition) @add_condition.register @@ -251,7 +265,11 @@ def _(self, condition: Callable): return super().add_condition(condition) @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 + ) -> "NetworkTrace[T]": """ Adds a `QueueCondition` to the traversal. However, before registering it with the traversal, it will make sure that the queue condition is only checked on step types relevant to the `NetworkTraceActionType` assigned to this instance. That is when: @@ -264,6 +282,7 @@ def add_queue_condition(self, condition: NetworkTraceQueueCondition[NetworkTrace :param condition: The queue condition to add. :returns: This `NetworkTrace` instance """ + return super().add_queue_condition(condition) @add_queue_condition.register @@ -284,6 +303,7 @@ def add_stop_condition(self, condition: StopConditionTypes, step_type: NetworkTr :param condition: The stop condition to add. :returns: This `NetworkTrace` instance """ + return super().add_stop_condition(condition) @add_stop_condition.register(Callable) @@ -333,7 +353,7 @@ 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: 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 8b595dcaf..961ff5bb4 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 @@ -48,6 +48,7 @@ class Path: :param traversed_ac_line_segment: If the from_terminal and to_terminal path was via an `AcLineSegment`, this is the segment that was traversed :param nominal_phase_paths: A list of nominal phase paths traced in this step. If this is empty, phases have been ignored. """ + from_terminal: Terminal to_terminal: Terminal traversed_ac_line_segment: Optional[AcLineSegment] = field(default=None) @@ -60,9 +61,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") @@ -70,9 +69,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") @@ -80,16 +77,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 @@ -110,11 +103,12 @@ 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): 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 aeeec751f..cad8ca804 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 @@ -27,6 +27,7 @@ __all__ = ['NetworkStateOperators', 'NormalNetworkStateOperators', 'CurrentNetworkStateOperators'] + # noinspection PyPep8Naming class NetworkStateOperators(OpenStateOperators, FeederDirectionStateOperations, @@ -110,4 +111,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 cfb8c6738..eccbf68ed 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py @@ -17,13 +17,14 @@ class Tracing: @staticmethod - def network_trace(network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, - action_step_type: NetworkTraceActionType=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]: + def network_trace( + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, + action_step_type: NetworkTraceActionType=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. @@ -32,10 +33,11 @@ def network_trace(network_state_operators: Type[NetworkStateOperators]=NetworkSt :param queue: The traversal queue the trace is backed by. Defaults to a depth first queue. :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. + :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)) @@ -47,14 +49,31 @@ def network_trace(network_state_operators: Type[NetworkStateOperators]=NetworkSt 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, - 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]: + def network_trace_branching( + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, + action_step_type: NetworkTraceActionType=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)) 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 0cb8e4a22..4c5e9d06a 100644 --- a/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py +++ b/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py @@ -48,13 +48,13 @@ async def run(self, network: NetworkService, network_state_operators: Type[Netwo :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, 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], debug_logger: Logger=None): self.state_operators = state_operators @@ -190,7 +190,6 @@ 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(debug_logger=self._debug_logger) for other in terminal.other_terminals(): @@ -203,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 0b5926473..ab975c485 100644 --- a/src/zepben/evolve/services/network/tracing/phases/remove_phases.py +++ b/src/zepben/evolve/services/network/tracing/phases/remove_phases.py @@ -39,10 +39,11 @@ class RemovePhases(object): 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): + 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` @@ -54,6 +55,7 @@ async def run(self, :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): @@ -72,6 +74,7 @@ async def _run_with_network(network_service: NetworkService, network_state_opera :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 @@ -82,12 +85,14 @@ async def _run_with_terminal(self, terminal: Terminal, network_state_operators: :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 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 c4b9fe664..b023621cb 100644 --- a/src/zepben/evolve/services/network/tracing/phases/set_phases.py +++ b/src/zepben/evolve/services/network/tracing/phases/set_phases.py @@ -48,10 +48,11 @@ 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): + 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) @@ -65,30 +66,34 @@ async def run(self, else: raise Exception('INTERNAL ERROR: incorrect params') - async def _run(self, - network: NetworkService, - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + async def _run( + self, + network: NetworkService, + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): """ Apply phases from all sources in the 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): + async def _run_with_phases( + self, + terminal: Terminal, + phases: Union[PhaseCode, Iterable[SinglePhaseKind]], + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): """ 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 validate_phases(_phases): if len(_phases) != len(terminal.phases.single_phases): raise TracingException( @@ -108,11 +113,12 @@ def validate_phases(_phases): await self._run_terminal(terminal, network_state_operators) - async def run_spread_phases_and_flow(self, - seed_terminal: Terminal, - start_terminal: Terminal, - phases: List[SinglePhaseKind], - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + async def run_spread_phases_and_flow( + self, + seed_terminal: Terminal, + start_terminal: Terminal, + phases: List[SinglePhaseKind], + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): nominal_phase_paths = self._get_nominal_phase_paths(network_state_operators, seed_terminal, start_terminal, list(phases)) if await self._flow_phases(network_state_operators, seed_terminal, start_terminal, nominal_phase_paths): @@ -134,6 +140,7 @@ async def spread_phases( :param phases: The nominal phases on which to spread phases. :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: @@ -182,19 +189,23 @@ def inner(step, _, next_path): return ComputeData(inner) @staticmethod - def _apply_phases(state_operators: Type[NetworkStateOperators], - terminal: Terminal, - phases: List[SinglePhaseKind]): + def _apply_phases( + state_operators: Type[NetworkStateOperators], + terminal: Terminal, + phases: List[SinglePhaseKind]): 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] + ) -> tuple[NominalPhasePath]: + 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) @@ -204,11 +215,12 @@ def _get_nominal_phase_paths(self, state_operators: Type[NetworkStateOperators], return 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: + async def _flow_phases( + state_operators: Type[NetworkStateOperators], + from_terminal: Terminal, + to_terminal: Terminal, + nominal_phase_paths: Iterable[NominalPhasePath] + ) -> bool: from_phases = state_operators.phase_status(from_terminal) to_phases = state_operators.phase_status(to_terminal) 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 31bef5a3b..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 @@ -16,12 +16,12 @@ 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. diff --git a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py index d30786fd4..34e15c46d 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py +++ b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py @@ -22,6 +22,7 @@ class DebugLoggingWrapper: StopCondition: [], QueueCondition: [] } + def __init__(self, description: str, logger: Logger): self.description: str = description self._logger: Logger = logger @@ -50,6 +51,7 @@ def wrapobj(_clazz: Type[Wrappable]) -> int: based on their basic classification without requiring any information in the object aside from what it inherits from """ + self._wrapped[clazz].append(obj) if count is not None: return count @@ -65,12 +67,14 @@ def wrapattr(attr: str, msg: str) -> None: args/kwargs passed to the function are passed to `str.format()`, as is `result` which is the result of the function itself """ + setattr(obj, attr, self._log_method_call(getattr(obj, attr), msg)) # FIXME: when we drop 3.9 support, this can be replaced with a match case statement based # on the below one-liner, and multiple calls to _count can be dropped as we will know the # class before hitting any of the case blocks. # _subtype = [t for t in (StepAction, StopCondition, QueueCondition) if t in type(obj).mro()].pop() or None + if isinstance(obj, clazz := StepAction): _count = wrapobj(clazz) wrapattr('apply', f'{self.description}: stepping_on({_count})' + ' [item={args[0]}, context={args[1]}]') @@ -97,6 +101,7 @@ def _log_method_call(self, func: FunctionType, log_string: str): :param func: any callable :param log_string: any string supported by `str.format()` """ + def wrapper(*args, **kwargs): result = func(*args, **kwargs) msg = f"{self._logger.name}: {log_string.format(result=result, args=args, kwargs=kwargs)}" 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 49d66b834..9af6c166c 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/queue_condition.py +++ b/src/zepben/evolve/services/network/tracing/traversal/queue_condition.py @@ -31,24 +31,26 @@ 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 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 @@ -56,7 +58,7 @@ def should_queue_start_item(item: T) -> bool: 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. 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 dcf6a808e..73f21cefc 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/step_action.py +++ b/src/zepben/evolve/services/network/tracing/traversal/step_action.py @@ -23,25 +23,28 @@ class StepAction(Generic[T]): `T` The type of items being traversed. """ + def __init__(self, _func: StepActionFunc): self._func = _func def apply(self, item: T, context: StepContext): """ - Applies the action to the specified [item]. + Applies the action to the specified `item`. :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], 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 `StepContext`. `T` The type of items being traversed. `U` The type of the context value computed and used in the action. """ + def __init__(self, _func: StepActionFunc, key: str): StepAction.__init__(self, _func) ContextValueComputer.__init__(self, key) 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 df5c1c9b9..0e6693f4d 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/step_context.py +++ b/src/zepben/evolve/services/network/tracing/traversal/step_context.py @@ -13,14 +13,15 @@ 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): @@ -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,6 +51,7 @@ 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) def __str__(self) -> str: 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 d2ef6c21d..db6e25bc7 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/stop_condition.py +++ b/src/zepben/evolve/services/network/tracing/traversal/stop_condition.py @@ -21,8 +21,9 @@ class StopCondition(Generic[T], TraversalCondition[T]): """ Functional interface representing a condition that determines whether the traversal should stop at a given item. - T : The type of items being traversed. + `T` The type of items being traversed. """ + def __init__(self, stop_function: ShouldStop=None): if stop_function is not None: self.should_stop = stop_function @@ -42,8 +43,8 @@ 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 diff --git a/src/zepben/evolve/services/network/tracing/traversal/traversal.py b/src/zepben/evolve/services/network/tracing/traversal/traversal.py index 274e9a7fc..ad923c6d3 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/traversal.py +++ b/src/zepben/evolve/services/network/tracing/traversal/traversal.py @@ -53,8 +53,10 @@ class Traversal(Generic[T, D]): This class is **not thread safe**. - - T: The type of object to be traversed. - - D: The specific type of traversal, extending [Traversal]. + `T` The type of object to be traversed. + `D` The specific type of traversal, extending [Traversal]. + + :var name: The name of the traversal. Can be used for logging purposes and will be included in all debug logging. """ class QueueType(Generic[T, D]): @@ -64,6 +66,7 @@ class QueueType(Generic[T, D]): :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: TraversalQueue[T] @@ -81,8 +84,10 @@ class BasicQueueType(QueueType[T, D]): """ Basic queue type that handles non-branching item queuing. - :var queue_next: Logic for queueing the next item in the traversal. + :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]): self.queue_next = queue_next self._queue = queue @@ -90,9 +95,7 @@ def __init__(self, queue_next: Traversal.QueueNext[T], queue: TraversalQueue[T]) @property def queue(self) -> TraversalQueue[T]: - """ - The primary queue of items. - """ + """The primary queue of items.""" return self._queue @property @@ -105,10 +108,11 @@ class BranchingQueueType(QueueType[T, D]): 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]], @@ -126,10 +130,6 @@ def branch_queue(self) -> Optional[TraversalQueue[D]]: return self.branch_queue_factory() name: str - """ - The name of the traversal. Can be used for logging purposes and will - be included in all debug logging. - """ def __init__(self, queue_type, parent: Optional[D]=None, debug_logger: Logger=None): self._queue_type = queue_type @@ -160,6 +160,7 @@ def with_logger(self, logger: Logger) -> D: :param logger: the logger to use :return: self """ + self._debug_logger = DebugLoggingWrapper(self.name, logger) return self @@ -183,8 +184,9 @@ def can_action_item(self, item: T, context: StepContext) -> bool: `item` The item to check. `context` The context of the current traversal step. - Returns `true` if the item can be acted upon; `false` otherwise. + Returns `True` if the item can be acted upon; `False` otherwise. """ + return True def can_visit_item(self, item: T, context: StepContext) -> bool: @@ -194,13 +196,14 @@ def create_new_this(self) -> D: """ Creates a new instance of the traversal for branching purposes. - 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. + 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 @@ -212,6 +215,7 @@ 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: return self.add_stop_condition(condition) @@ -229,12 +233,13 @@ def add_condition(self, condition: ConditionTypes) -> D: 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 + `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) @@ -256,9 +261,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 @@ -280,6 +286,7 @@ def add_queue_condition(self, condition: QueueConditionTypes) -> D: :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) @@ -304,6 +311,7 @@ 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 @@ -313,9 +321,10 @@ def add_step_action(self, action: Union[StepActionFunc, StepAction[T]]) -> D: Adds an action to be performed on each item in the traversal, including the starting items. - `action` The action to perform on each item. - Returns The current traversal instance. + :param action: The action to perform on each item. + :return: The current traversal instance. """ + if isinstance(action, StepAction): if self._debug_logger is not None: @@ -335,14 +344,16 @@ def if_not_stopping(self, action: Callable[[T, StepContext], None]) -> D: """ Adds an action to be performed on each item that does not match any stop condition. - `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. """ + # TODO: at the moment were assuming a function being passed in, so we can turn it into # a step action here, this prevents StepActionWithContextValue being passed in, however # in future we want to allow passing step actions in here. the JVMSDK throws an error # if you pass context aware step actions into here, though why cant we just send this # on to `add_step_action`... + step_action = StepAction(lambda it, context: action(it, context) if not context.is_stopping else None) if self._debug_logger is not None: @@ -356,14 +367,16 @@ def if_stopping(self, action: Callable[[T, StepContext], None]) -> 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. """ + # TODO: at the moment were assuming a function being passed in, so we can turn it into # a step action here, this prevents StepActionWithContextValue being passed in, however # in future we want to allow passing step actions in here. the JVMSDK throws an error # if you pass context aware step actions into here, though why cant we just send this # on to `add_step_action`... + step_action = StepAction(lambda it, context: action(it, context) if context.is_stopping else None) if self._debug_logger is not None: @@ -376,9 +389,10 @@ 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 @@ -393,12 +407,13 @@ 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] + 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") self.compute_next_context_funs[computer.key] = computer return self @@ -407,12 +422,13 @@ 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] + 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) @@ -439,6 +455,7 @@ def add_start_item(self, item: T) -> D: :param item: The item to add. :return: The current traversal instance. """ + self.start_items.append(item) return self @@ -452,6 +469,7 @@ async def run(self, start_item: T=None, can_stop_on_start_item: bool=True) -> D: on the starting item. :return: The current traversal instance. """ + if start_item is not None: self.start_items.append(start_item) @@ -482,6 +500,7 @@ def reset(self) -> D: :return: The current traversal instance. """ + require(not self.running, lambda: "Traversal is currently running.") self.has_run = False self.queue.clear() @@ -498,6 +517,7 @@ def on_reset(self): Called when the traversal is reset. Derived classes can override this to reset additional state. """ + raise NotImplementedError() def _branch_start_items(self): @@ -540,7 +560,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) @@ -616,6 +635,7 @@ def __init__(self, func): def accept(self, item: T, context: StepContext, queue_item: Callable[[T], bool]) -> bool: return self._func(item, context, queue_item) + class BranchingQueueNext(Generic[T]): def __init__(self, func): self._func = func From 76540abfae1332d267bbfd29929c15deeeccc558 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Wed, 4 Jun 2025 20:22:42 +1000 Subject: [PATCH 10/28] more docstrings, some code changes to remove __code__ dunder usage in favor of builtins Signed-off-by: Max Chesterfield --- .../network/tracing/feeder/set_direction.py | 2 +- .../tracing/networktrace/network_trace.py | 68 +++++++++++++++---- .../network/tracing/traversal/traversal.py | 44 ++++++------ .../evolve/testing/test_network_builder.py | 10 ++- 4 files changed, 86 insertions(+), 38 deletions(-) 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 f5e3d2b23..2457e4198 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/set_direction.py +++ b/src/zepben/evolve/services/network/tracing/feeder/set_direction.py @@ -125,7 +125,7 @@ async def run(self, network: NetworkService, network_state_operators: Type[Netwo """ 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 (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) 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 443b155c5..e46ffdd7c 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py @@ -2,7 +2,7 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. - +import inspect from collections.abc import Callable from functools import singledispatchmethod from logging import Logger @@ -144,56 +144,94 @@ def branching( @singledispatchmethod def add_start_item(self, start: Union[Terminal, ConductingEquipment], data: T=None, phases: PhaseCode=None) -> "NetworkTrace[T]": """ - Depending on the type of `start`, adds either: - - A starting [Terminal] to the trace with the associated step data. - - 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 `ConductingEquipment`. + - All terminals of the given `AcLineSegment`. 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 `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 `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 `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 `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 `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 `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 def _add_start_item(self, start: Terminal=None, @@ -260,7 +298,7 @@ def _(self, condition: Callable): >>> NetworkTrace().add_condition(stop_at_open()) """ - if condition.__code__.co_argcount == 1: # Catches DSL Style lambda conditions from zepben.evolve.Conditions + 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)) return super().add_condition(condition) diff --git a/src/zepben/evolve/services/network/tracing/traversal/traversal.py b/src/zepben/evolve/services/network/tracing/traversal/traversal.py index ad923c6d3..73093255f 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/traversal.py +++ b/src/zepben/evolve/services/network/tracing/traversal/traversal.py @@ -5,6 +5,7 @@ from __future__ import annotations +import inspect from abc import abstractmethod from collections import deque from collections.abc import Callable @@ -29,6 +30,8 @@ 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]] @@ -54,12 +57,15 @@ class Traversal(Generic[T, D]): 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 `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. @@ -67,20 +73,20 @@ class QueueType(Generic[T, D]): :var queue: The primary queue of items. """ - queue_next: Traversal.QueueNext[T] - queue: TraversalQueue[T] + queue_next: Traversal.QueueNext[QT] + queue: TraversalQueue[QT] @property @abstractmethod - def queue(self) -> TraversalQueue[T]: + 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]): + class BasicQueueType(QueueType[QT, QD]): """ Basic queue type that handles non-branching item queuing. @@ -88,22 +94,22 @@ class BasicQueueType(QueueType[T, D]): :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]): + class BranchingQueueType(QueueType[QT, QD]): """ Branching queue type, supporting operations that may split into separate branches during traversal. @@ -114,19 +120,19 @@ class BranchingQueueType(QueueType[T, D]): """ 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 + 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() name: str @@ -217,9 +223,9 @@ def add_condition(self, condition: ConditionTypes) -> D: """ if callable(condition): # Callable[[NetworkTraceStep[T], StepContext], None] - if condition.__code__.co_argcount == 2: + 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)') diff --git a/src/zepben/evolve/testing/test_network_builder.py b/src/zepben/evolve/testing/test_network_builder.py index c3f966b48..538496c1f 100644 --- a/src/zepben/evolve/testing/test_network_builder.py +++ b/src/zepben/evolve/testing/test_network_builder.py @@ -470,7 +470,9 @@ 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')) + terminal = Terminal(mrid=f'{clamp.mrid}-t1') + self.network.add(terminal) + clamp.add_terminal(terminal) acls.add_clamp(clamp) action(clamp) @@ -501,8 +503,10 @@ 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]: + t = Terminal(mrid=f'{cut.mrid}-t{i}') + self.network.add(t) + cut.add_terminal(t) cut.set_normally_open(is_normally_open) if is_open is None: From e70c7a7e6a1626f8d0fc3d5dc58b82994eace9e3 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Wed, 4 Jun 2025 20:25:19 +1000 Subject: [PATCH 11/28] moar docstrings Signed-off-by: Max Chesterfield --- .../services/network/tracing/networktrace/network_trace.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 e46ffdd7c..518e520b4 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py @@ -37,7 +37,7 @@ 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 `Traversal` implementation specifically designed to trace connected `Terminal`s of `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. @@ -289,10 +289,10 @@ def add_condition(self, condition: QueueCondition[T]) -> "NetworkTrace[T]": @add_condition.register def _(self, condition: Callable): """ - 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 `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 `Conditions.stop_at_open` factory, you can use: >>> from zepben.evolve import stop_at_open >>> NetworkTrace().add_condition(stop_at_open()) From fa65267bc67fe9a712aadef3ff77ae9e8d313721 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Wed, 4 Jun 2025 20:28:00 +1000 Subject: [PATCH 12/28] DEV-3188-Network-Trace-can-start-on-path Signed-off-by: Max Chesterfield --- changelog.md | 5 +- .../tracing/networktrace/network_trace.py | 45 +++++++++++--- .../networktrace/test_network_trace.py | 62 ++++++++++++++++++- 3 files changed, 100 insertions(+), 12 deletions(-) diff --git a/changelog.md b/changelog.md index 4c2f4003f..0187c0a07 100644 --- a/changelog.md +++ b/changelog.md @@ -35,8 +35,9 @@ * You can now add sites to the `TestNetworkBuilder` via `addSite`. * You can now add busbar sections natively with `from_busbar_section` and `to_busbar_section` * The prefix for generated mRIDs for "other" equipment can be specified with the `default_mrid_prefix` argument in `from_other` and `to_other`. -* When processing feeder assignments, all LV feeders belonging to a dist substation site will now be considered energized when the site is energized by a - feeder. +* 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. ### Fixes * When finding `LvFeeders` in the `Site` we will now exclude `LvFeeders` that start with an open `Switch` 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 518e520b4..143d94ee6 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py @@ -142,13 +142,14 @@ def branching( 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 one of the following as starting points in the trace, along with the associated data: - A starting `Terminal` - All terminals of the given `ConductingEquipment`. - All terminals of the given `AcLineSegment`. + - The `NetworkTraceStep.Path` passed in. Tracing will be only external from this terminal and not trace internally back through its conducting equipment. @@ -178,7 +179,6 @@ def _(self, start: ConductingEquipment, data=None, phases=None): # we want to trace externally from it and traverse its segment. for it in start.terminals: self._add_start_item(it, data=data, phases=phases) - return self @add_start_item.register @@ -233,20 +233,47 @@ def start_terminals() -> Generator[Terminal, None, None]: 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: Terminal=None, - data: T=None, - phases: PhaseCode=None, - traversed_ac_line_segment: AcLineSegment=None): + start: Union[Terminal, NetworkTraceStep.Path]=None, + 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 `NetworkTraceStep.Path`s for the start item + and adds it to the `Traversal` + + If `start` is a `NetworkTraceStep.Path`, [`phases`, `traversed_ac_line_segment`] will all be ignored. + + :param start: The starting `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 AcLineSegment that was just traversed + + :returns: This `NetworkTrace` instance + """ if start is None: - return - start_path = NetworkTraceStep.Path(start, start, traversed_ac_line_segment, self.start_nominal_phase_path(phases)) + raise ValueError('path and start must not both be None.') + + 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)) + super().add_start_item(NetworkTraceStep(start_path, 0, 0, data)) async def run( self, - start: Union[ConductingEquipment, Terminal]=None, + start: Union[ConductingEquipment, Terminal, NetworkTraceStep.Path]=None, data: T=None, phases: PhaseCode=None, can_stop_on_start_item: bool=True diff --git a/test/services/network/tracing/networktrace/test_network_trace.py b/test/services/network/tracing/networktrace/test_network_trace.py index 741ac309d..70178433b 100644 --- a/test/services/network/tracing/networktrace/test_network_trace.py +++ b/test/services/network/tracing/networktrace/test_network_trace.py @@ -6,7 +6,7 @@ import sys DEFAULT_RECURSION_LIMIT = sys.getrecursionlimit() -from typing import List, Set +from typing import List, Set, Tuple import pytest @@ -224,3 +224,63 @@ 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"]) + + From 79cecbf1cd4aa2bfd2d325b189ddcbe465779e9d Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Thu, 5 Jun 2025 04:31:06 +1000 Subject: [PATCH 13/28] DEV-3189-assign-phases-bug Signed-off-by: Max Chesterfield --- changelog.md | 2 + .../connectivity/transformer_phase_paths.py | 86 ++-- .../network/tracing/phases/phase_inferrer.py | 2 +- .../network/tracing/phases/set_phases.py | 459 +++++++++++++----- .../tracing/phases/test_phase_inferrer.py | 5 +- .../network/tracing/phases/test_set_phases.py | 339 ++++++++++++- 6 files changed, 712 insertions(+), 181 deletions(-) diff --git a/changelog.md b/changelog.md index 0187c0a07..400912bd4 100644 --- a/changelog.md +++ b/changelog.md @@ -52,6 +52,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/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/phases/phase_inferrer.py b/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py index 4c5e9d06a..375044dd7 100644 --- a/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py +++ b/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py @@ -193,7 +193,7 @@ async def _infer_xy_phases(self, terminal: Terminal, max_missing_phases: int, tr async def _continue_phases(self, terminal: Terminal): set_phases_trace = Tracing.set_phases(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: 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 b023621cb..6ce2c87c7 100644 --- a/src/zepben/evolve/services/network/tracing/phases/set_phases.py +++ b/src/zepben/evolve/services/network/tracing/phases/set_phases.py @@ -6,12 +6,15 @@ from __future__ import annotations from collections.abc import Sequence -from typing import Union, Set, Iterable, List, Type, TYPE_CHECKING +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 @@ -47,122 +50,179 @@ def __init__(self, nominal_phase_paths: Iterable[NominalPhasePath], step_flowed_ def __str__(self): return f'PhasesToFlow(nominal_phase_paths={self.nominal_phase_paths}, step_flowed_phases={self.step_flowed_phases})' - + @singledispatchmethod async def run( self, - apply_to: Union[NetworkService, Terminal], + target: 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) - - elif isinstance(apply_to, Terminal): - if phases is None: - return await self._run_terminal(apply_to, network_state_operators) - - return await self._run_with_phases(apply_to, phases, network_state_operators) + :param target: + :param phases: + :param network_state_operators: The `NetworkStateOperators` to be used when setting phases. + """ - else: - raise Exception('INTERNAL ERROR: incorrect params') + raise ValueError('INTERNAL ERROR: incorrect params') - async def _run( + @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. + :param network_state_operators: The `NetworkStateOperators` to be used when setting 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) + 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 - async def _run_with_phases( + await self._run_terminals(_terminals_from_network(), network_state_operators=network_state_operators) + + @run.register + async def _( self, - terminal: Terminal, - phases: Union[PhaseCode, Iterable[SinglePhaseKind]], - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + start_terminal: Terminal, + phases: Union[PhaseCode, List[SinglePhaseKind]]=None, + network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, + seed_terminal: Terminal=None): """ - Apply phases from the `terminal`. + 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` - :param terminal: The terminal to start applying phases from. - :param phases: The phases to apply. Must only contain ABCN. + :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. """ - 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 - - if isinstance(phases, PhaseCode): - self._apply_phases(network_state_operators, terminal, validate_phases(phases.single_phases)) - - 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 - ): + 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) + 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): + paths = self._get_nominal_phase_paths(network_state_operators, from_terminal, to_terminal, 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) ) + # 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, @@ -170,10 +230,11 @@ async def step_action(nts, ctx): 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( - lambda next_step, *args: len(next_step.data.nominal_phase_paths) > 0 + lambda next_step, x, y, z: len(next_step.data.nominal_phase_paths) > 0 ) .add_step_action(step_action) ) @@ -184,15 +245,20 @@ 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], + phases: List[SinglePhaseKind], terminal: Terminal, - phases: List[SinglePhaseKind]): + state_operators: Type[NetworkStateOperators]): traced_phases = state_operators.phase_status(terminal) for i, nominal_phase in enumerate(terminal.phases.single_phases): @@ -203,81 +269,218 @@ def _get_nominal_phase_paths( state_operators: Type[NetworkStateOperators], from_terminal: Terminal, to_terminal: Terminal, - phases: Sequence[SinglePhaseKind] - ) -> tuple[NominalPhasePath]: + 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( + 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: Iterable[NominalPhasePath] + 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. + + for path in paths: + if path.from_phase == SinglePhaseKind.NONE: + self._try_add_phase(from_terminal, from_phases, to_terminal, to_phases, path.to_phase, allow_suspect_flow, + lambda: updated_phases.append(True)) + else: + self._try_set_phase(from_phases[path.from_phase], from_terminal, from_phases, path.from_phase, + to_terminal, to_phases, path.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 + + 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: + to_phases[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. - 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) + 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/test/services/network/tracing/phases/test_phase_inferrer.py b/test/services/network/tracing/phases/test_phase_inferrer.py index 65141073d..040063326 100644 --- a/test/services/network/tracing/phases/test_phase_inferrer.py +++ b/test/services/network/tracing/phases/test_phase_inferrer.py @@ -452,7 +452,10 @@ class LoggerOnly: 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..ee2d00a8e 100644 --- a/test/services/network/tracing/phases/test_set_phases.py +++ b/test/services/network/tracing/phases/test_set_phases.py @@ -2,6 +2,8 @@ # 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) @@ -12,21 +14,22 @@ 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 _create_network_trace(self, state_operators: NetworkStateOperators, partially_energised_transformers) -> 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)) \ + return (super()._create_network_trace(state_operators, partially_energised_transformers)) \ .add_step_action(log_step) SetPhases = LoggingSetPhases +""" @pytest.mark.asyncio @pytest.mark.parametrize('phase_swap_loop_network', [(False,)], indirect=True) @@ -178,8 +181,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 +208,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,9 +234,266 @@ 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. + for phase_code in (PhaseCode.ABC, PhaseCode.AB, PhaseCode.BC, PhaseCode.AC): + await _validate_tx_phases(*[phase_code]*5) + + for phase_code in (PhaseCode.AB, PhaseCode.BC, PhaseCode.AC): + await _validate_tx_phases(phase_code, phase_code, PhaseCode.XY, phase_code, phase_code) + + for phase_code in (PhaseCode.AB, PhaseCode.BC, PhaseCode.AC): + await _validate_tx_phases(phase_code, PhaseCode.XY, PhaseCode.XY, phase_code, phase_code) + + for phase_code in (PhaseCode.A, PhaseCode.B, PhaseCode.C): + await _validate_tx_phases(*[phase_code]*5) + + for phase_code in (PhaseCode.A, PhaseCode.B, PhaseCode.C): + await _validate_tx_phases(phase_code, phase_code, PhaseCode.X, phase_code, phase_code) + + for phase_code in (PhaseCode.A, PhaseCode.B, PhaseCode.C): + await _validate_tx_phases(phase_code, PhaseCode.X, PhaseCode.X, phase_code, phase_code) + + # With neutral. + for phase_code in (PhaseCode.ABC, PhaseCode.AB, PhaseCode.BC, PhaseCode.AC): + await _validate_tx_phases(phase_code, phase_code, phase_code + PhaseCode.N, phase_code, phase_code + PhaseCode.N) + + for phase_code in (PhaseCode.AB, PhaseCode.BC, PhaseCode.AC): + await _validate_tx_phases(phase_code, phase_code, PhaseCode.XYN, phase_code, phase_code + PhaseCode.N) + + for phase_code in (PhaseCode.AB, PhaseCode.BC, PhaseCode.AC): + await _validate_tx_phases(phase_code, PhaseCode.XY, PhaseCode.XYN, phase_code, phase_code + PhaseCode.N) + + for phase_code in (PhaseCode.A, PhaseCode.B, PhaseCode.C): + await _validate_tx_phases(phase_code, phase_code, phase_code + PhaseCode.N, phase_code, phase_code + PhaseCode.N) + + for phase_code in (PhaseCode.A, PhaseCode.B, PhaseCode.C): + await _validate_tx_phases(phase_code, phase_code, PhaseCode.XN, phase_code, phase_code + PhaseCode.N) + + for phase_code in (PhaseCode.A, PhaseCode.B, PhaseCode.C): + await _validate_tx_phases(phase_code, PhaseCode.X, PhaseCode.XN, phase_code, phase_code + PhaseCode.N) + +@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 +536,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): @@ -305,3 +570,61 @@ 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) From 984243c85222be5ce08942c9792313f0a5c85b72 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Thu, 5 Jun 2025 16:30:50 +1000 Subject: [PATCH 14/28] missed name for FindSwerEquipment._create_trace Signed-off-by: Max Chesterfield --- .../evolve/services/network/tracing/find_swer_equipment.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 4965b8df7..ef810570b 100644 --- a/src/zepben/evolve/services/network/tracing/find_swer_equipment.py +++ b/src/zepben/evolve/services/network/tracing/find_swer_equipment.py @@ -91,7 +91,8 @@ async def find_on_feeder(self, feeder: Feeder, network_state_operators: Type[Net def _create_trace(self, state_operators: Type[NetworkStateOperators]) -> NetworkTrace[T]: return Tracing.network_trace( network_state_operators=state_operators, - debug_logger=self._debug_logger + debug_logger=self._debug_logger, + name=f'FindSwerEquipment({state_operators.description})' ).add_condition(stop_at_open()) async def _trace_from(self, state_operators: Type[NetworkStateOperators], transformer: PowerTransformer, swer_equipment: Set[ConductingEquipment]): From 7752745d8fb948741f307c30475efc969256cfe1 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Fri, 6 Jun 2025 02:01:29 +1000 Subject: [PATCH 15/28] allow deliberate rewrapping with DebugLoggingWrapper need to expose this via either the `.add*` commands in `Traversal`, a class attribute on `Traversal` or a global. Signed-off-by: Max Chesterfield --- .../tracing/traversal/debug_logging.py | 97 ++++++----- .../traversal/test_debug_logging_wrapper.py | 159 +++++++++++++++++- 2 files changed, 208 insertions(+), 48 deletions(-) diff --git a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py index 34e15c46d..5cf26b936 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py +++ b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py @@ -2,6 +2,7 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. +import functools from logging import Logger from types import FunctionType from typing import TypeVar, Union, cast, Optional, Type @@ -12,22 +13,29 @@ T = TypeVar('T') +__all__ = ['DebugLoggingWrapper'] + + Wrappable = Union[StepAction[T], QueueCondition[T], StopCondition[T]] +data = { + StepAction: [('apply', ' [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: - _wrapped = { - StepAction: [], - StopCondition: [], - QueueCondition: [] - } +class DebugLoggingWrapper: def __init__(self, description: str, logger: Logger): self.description: str = description self._logger: Logger = logger + self._wrapped = {StepAction: [], StopCondition: [], QueueCondition: []} - def wrap(self, obj: Wrappable, count: Optional[int]=None): + def wrap(self, obj: Wrappable, count: Optional[int] = None, allow_re_wrapping: bool = False): """ Wrap, in place, supported methods of the object passed in. @@ -40,71 +48,78 @@ def wrap(self, obj: Wrappable, count: Optional[int]=None): - should_queue - should_queue_start_item - :param obj: instantiated object representing a condition or action in a `Traversal` - :param count: (optional) set the `count` in the log message + :param obj: Instantiated object representing a condition or action in a `Traversal` + :param count: (optional) Set the `count` in the log message + :param allow_re_wrapping: (optional) Replace the existing logging wrapper, if it exists. :return: the object passed in for fluent use """ - def wrapobj(_clazz: Type[Wrappable]) -> int: + def get_logger_index(_clazz: Type[Wrappable], _attr: str) -> 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].append(obj) + # We need to check if the object has already been wrapped with logging so we can determine the + # count number we should use. + if hasattr(getattr(obj, _attr), '__wrapped__'): + # It has been, now we need to decide whether to use the previously assigned count by this class + # or - if it was wrapped with another class, we need to generate a new one. + if obj in self._wrapped[clazz]: + # if it was wrapped by this class, return the original count + return self._wrapped[clazz].index(obj) + 1 + + if obj not in self._wrapped[clazz]: + self._wrapped[clazz].append(obj) + + # if we had a requested count number passed in, use it if count is not None: return count + return len(self._wrapped[clazz]) - def wrapattr(attr: str, msg: str) -> None: + def wrap_attr(_attr: str) -> None: """ Replaces the specified attr with a wrapper around the same attr to inject logging. - :param attr: Method/Function name. - :param msg: Log message format string to output when `attr` is called. - args/kwargs passed to the function are passed to `str.format()`, - as is `result` which is the result of the function itself + :param _attr: Method/Function name. """ - setattr(obj, attr, self._log_method_call(getattr(obj, attr), msg)) - - # FIXME: when we drop 3.9 support, this can be replaced with a match case statement based - # on the below one-liner, and multiple calls to _count can be dropped as we will know the - # class before hitting any of the case blocks. - # _subtype = [t for t in (StepAction, StopCondition, QueueCondition) if t in type(obj).mro()].pop() or None - - if isinstance(obj, clazz := StepAction): - _count = wrapobj(clazz) - wrapattr('apply', f'{self.description}: stepping_on({_count})' + ' [item={args[0]}, context={args[1]}]') - - elif isinstance(obj, clazz := StopCondition): - _count = wrapobj(clazz) - wrapattr('should_stop', f'{self.description}: should_stop({_count})' + '={result} [item={args[0]}, context={args[1]}]') - - elif isinstance(obj, clazz := QueueCondition): - _count = wrapobj(clazz) - wrapattr('should_queue', f'{self.description}: should_queue({_count})' + ( - '={result} [next_item={args[0]}, next_context={args[1]}, current_item={args[2]}, current_context={args[3]}]')) - wrapattr('should_queue_start_item', f'{self.description}: should_queue_start_item({_count})' + '={result} [item={args[0]}]') - + # wrapped methods will have `__wrapped__` set to the original method that was wrapped - if it exists on + # the methods were interested in wrapping, the object has already been wrapped. We will re-wrap it, but + # only if we have been explicitly told its ok, otherwise we want to catch the bug. + if (to_wrap := getattr(obj, _attr)) and hasattr(to_wrap, '__wrapped__'): + if not allow_re_wrapping: + raise AttributeError(f'Wrappable cannot be rewrapped without explicitly specifying you would like to replace the logging wrapper') + to_wrap = getattr(to_wrap, '__wrapped__') + + setattr(obj, _attr, self._log_method_call(to_wrap, f'{self.description}: {_attr}({get_logger_index(clazz, _attr)})' + msg)) + + for clazz in (StepAction, StopCondition, QueueCondition): + if isinstance(obj, clazz): + for attr, msg in data.get(clazz): + wrap_attr(attr) + return obj else: - raise AttributeError(f'{type(self)} does not support wrapping {obj}') - # This cast is for type hints, without it, any object returned is treated as a combination of all types accepted as `obj` - return cast(clazz, obj) + raise AttributeError(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: any string supported by `str.format()` + :param log_string: Log message format string to output when `attr` is called. + args/kwargs passed to the function are passed to `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/test/services/network/tracing/traversal/test_debug_logging_wrapper.py b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py index e9ba137c5..e0cea2f1e 100644 --- a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py +++ b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py @@ -6,6 +6,8 @@ import queue from contextlib import contextmanager +import pytest + from zepben.evolve import StepContext, StopCondition, QueueCondition, StepAction from zepben.evolve.services.network.tracing.traversal.debug_logging import DebugLoggingWrapper @@ -88,7 +90,6 @@ def test_can_wrap_stop_condition(self): 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) self._wrap(condition, 50) @@ -102,10 +103,14 @@ def test_can_wrap_queue_conditions(self): 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(50)=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(50)=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(50)=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(50)=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(50)=False [item={self.item_1}]" assert handler.log_list.get() == f"root: my desc: should_queue_start_item(50)=True [item={self.item_2}]" @@ -116,5 +121,145 @@ def test_can_wrap_step_actions(self): 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: stepping_on(1) [item={self.item_1}, context={self.context_1}]" - assert handler.log_list.get() == f"root: my desc: stepping_on(1) [item={self.item_2}, context={self.context_2}]" + assert handler.log_list.get() == f"root: my desc: apply(1) [item={self.item_1}, context={self.context_1}]" + assert handler.log_list.get() == f"root: my desc: apply(1) [item={self.item_2}, context={self.context_2}]" + + def test_rewrapping_step_action_throws_attribute_error_when_allow_re_wrapping_is_false(self): + logging_wrapper = DebugLoggingWrapper('my desc', self.logger) + + action = StepAction(lambda item, context: None) + logging_wrapper.wrap(action) + + assert isinstance(action, StepAction) + assert action in logging_wrapper._wrapped[StepAction] + + with pytest.raises(AttributeError): + logging_wrapper.wrap(action) + + def test_rewrapping_step_action_works_when_allow_re_wrapping_is_true(self): + logging_wrapper = DebugLoggingWrapper('my desc', self.logger) + + action = StepAction(lambda item, context: None) + logging_wrapper.wrap(action) + assert len(logging_wrapper._wrapped[StepAction]) == 1 + + assert isinstance(action, StepAction) + assert action in logging_wrapper._wrapped[StepAction] + + logging_wrapper.wrap(action, allow_re_wrapping=True) + + # Make sure we didn't double add it. + assert len(logging_wrapper._wrapped[StepAction]) == 1 + + def test_rewrapping_queue_condition_throws_attribute_error_when_allow_re_wrapping_is_false(self): + logging_wrapper = DebugLoggingWrapper('my desc', self.logger) + + should_stop = bool_generator() + condition = QueueCondition(lambda nitem, nctx, item, ctx: next(should_stop)) + + logging_wrapper.wrap(condition) + + assert isinstance(condition, QueueCondition) + assert condition in logging_wrapper._wrapped[QueueCondition] + + with pytest.raises(AttributeError): + logging_wrapper.wrap(condition) + + def test_rewrapping_queue_condition_works_when_allow_re_wrapping_is_true(self): + logging_wrapper = DebugLoggingWrapper('my desc', self.logger) + + should_stop = bool_generator() + condition = QueueCondition(lambda nitem, nctx, item, ctx: next(should_stop)) + + assert condition.should_queue(False, False, False, False) + + logging_wrapper.wrap(condition) + + assert not condition.should_queue(False, False, False, False) + assert len(logging_wrapper._wrapped[QueueCondition]) == 1 + assert isinstance(condition, QueueCondition) + assert condition in logging_wrapper._wrapped[QueueCondition] + + logging_wrapper.wrap(condition, allow_re_wrapping=True) + + assert condition.should_queue(False, False, False, False) + + # Make sure we didn't double add it. + assert len(logging_wrapper._wrapped[QueueCondition]) == 1 + + def test_rewrapping_stop_condition_throws_attribute_error_when_allow_re_wrapping_is_false(self): + logging_wrapper = DebugLoggingWrapper('my desc', self.logger) + + condition = StopCondition(lambda item, context: True) + logging_wrapper.wrap(condition) + + assert isinstance(condition, StopCondition) + assert condition in logging_wrapper._wrapped[StopCondition] + + with pytest.raises(AttributeError): + logging_wrapper.wrap(condition) + + # ensure rewrapping conditions already wrapped by another logger requires explicit approval + logging_wrapper2 = DebugLoggingWrapper('my desc', self.logger) + with pytest.raises(AttributeError): + logging_wrapper2.wrap(condition) + + def test_rewrapping_stop_condition_works_when_allow_re_wrapping_is_true(self): + logging_wrapper = DebugLoggingWrapper('my desc', self.logger) + + condition = StopCondition(lambda item, context: True) + logging_wrapper.wrap(condition) + assert len(logging_wrapper._wrapped[StopCondition]) == 1 + + assert isinstance(condition, StopCondition) + assert condition in logging_wrapper._wrapped[StopCondition] + + logging_wrapper.wrap(condition, allow_re_wrapping=True) + + # Make sure we didn't double add it. + assert len(logging_wrapper._wrapped[StopCondition]) == 1 + + # ensure rewrapping conditions already wrapped by another logger works when specified + logging_wrapper2 = DebugLoggingWrapper('my desc', self.logger) + logging_wrapper2.wrap(condition, allow_re_wrapping=True) + + 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) + logging_wrapper.wrap(condition) + + # check count starts at 1, and double adding the same condition doesnt increment count + with self._log_handler() as handler: + condition.should_stop(False, False) + assert handler.log_list.get() == f"root: my desc: should_stop(1)=True [item=False, context=False]" + + logging_wrapper.wrap(condition, allow_re_wrapping=True) + 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) + logging_wrapper.wrap(condition2) + + with self._log_handler() as handler: + # check the new condition is marked as "2" + 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" + condition.should_stop(False, False) + assert handler.log_list.get() == f"root: my desc: should_stop(1)=True [item=False, context=False]" + + # check that addind the already wrapped conditions to a new logger resets the count. + logging_wrapper2 = DebugLoggingWrapper('my desc', self.logger) + + logging_wrapper2.wrap(condition, allow_re_wrapping=True) + logging_wrapper2.wrap(condition2, allow_re_wrapping=True) + + with self._log_handler() as handler: + 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" + condition2.should_stop(False, False) + assert handler.log_list.get() == f"root: my desc: should_stop(2)=True [item=False, context=False]" From bdba29d6661edf5d2924ae7fd96998f806b1852b Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Mon, 9 Jun 2025 16:58:54 +1000 Subject: [PATCH 16/28] allow adding StepAction and StepActionWithContextValue to Traversal as if_[not_]stopping() actions Signed-off-by: Max Chesterfield --- changelog.md | 4 + src/zepben/evolve/__init__.py | 2 + .../actions/equipment_tree_builder.py | 4 +- .../tracing/networktrace/network_trace.py | 233 +++++++------- .../networktrace/network_trace_step.py | 43 +-- .../tracing/traversal/debug_logging.py | 55 ++-- .../network/tracing/traversal/step_action.py | 39 ++- .../network/tracing/traversal/traversal.py | 168 +++++------ src/zepben/evolve/util.py | 24 +- .../traversal/test_debug_logging_wrapper.py | 6 +- .../tracing/traversal/test_traversal.py | 285 ++++++++++++------ 11 files changed, 513 insertions(+), 350 deletions(-) diff --git a/changelog.md b/changelog.md index 400912bd4..246e8afdd 100644 --- a/changelog.md +++ b/changelog.md @@ -20,12 +20,14 @@ * `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. * Added support to `TestNetworkBuilder` for: @@ -38,6 +40,8 @@ * 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` diff --git a/src/zepben/evolve/__init__.py b/src/zepben/evolve/__init__.py index 1e6c52492..24e8749de 100644 --- a/src/zepben/evolve/__init__.py +++ b/src/zepben/evolve/__init__.py @@ -205,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 * 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 a18ebd8e7..40006493c 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 @@ -37,7 +37,7 @@ class EquipmentTreeBuilder(StepActionWithContextValue): _roots: dict[ConductingEquipment, EquipmentTreeNode]={} def __init__(self): - super().__init__(_func=self.apply, key=str(uuid.uuid4())) + super().__init__(key=str(uuid.uuid4())) @property def roots(self) -> Generator[TreeNode[ConductingEquipment], None, None]: @@ -62,7 +62,7 @@ def compute_next_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) 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 143d94ee6..e282d901d 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py @@ -37,31 +37,31 @@ 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 + 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' @@ -70,17 +70,17 @@ 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, - debug_logger: Logger=None, - name: str=None + parent: 'NetworkTrace[T]' = None, + action_type: NetworkTraceActionType = None, + debug_logger: Logger = None, + name: str = None, ): if name is None: @@ -105,18 +105,17 @@ def non_branching( action_type: CanActionItem, name: str, compute_data: Union[ComputeData[T], ComputeDataWithPaths[T]], - debug_logger=None + 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) + 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( @@ -126,30 +125,34 @@ def branching( 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, + 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) + 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, NetworkTraceStep.Path], 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 one of the following as starting points in the trace, along with the associated data: + - A starting `Terminal` - - All terminals of the given `ConductingEquipment`. - - All terminals of the given `AcLineSegment`. - - The `NetworkTraceStep.Path` passed in. + - 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. @@ -165,14 +168,14 @@ def add_start_item(self, start: Union[Terminal, ConductingEquipment, NetworkTrac @add_start_item.register def _(self, start: ConductingEquipment, data=None, phases=None): """ - Adds all terminals of the given `ConductingEquipment` as starting points in the trace, with the associated data. + 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 `NetworkTrace` instance + :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, @@ -184,14 +187,14 @@ def _(self, start: ConductingEquipment, data=None, phases=None): @add_start_item.register def _(self, start: Terminal, data=None, phases=None): """ - Adds a starting `Terminal` to the trace with the associated step data. Tracing will be only external from this + 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 `NetworkTrace` instance + + :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 @@ -205,14 +208,14 @@ def _(self, start: Terminal, data=None, phases=None): @add_start_item.register def _(self, start: AcLineSegment, data=None, phases=None): """ - Adds all terminals of the given `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. + 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 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 `NetworkTrace` instance + :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, @@ -228,7 +231,6 @@ def start_terminals() -> Generator[Terminal, None, None]: for _terminal in cut.terminals: yield _terminal - for terminal in start_terminals(): self._add_start_item(terminal, data=data, phases=phases, traversed_ac_line_segment=start) return self @@ -240,21 +242,19 @@ def _(self, start: NetworkTraceStep.Path, data: T, phases=None): self._add_start_item(start, data=data) return self - def _add_start_item(self, - start: Union[Terminal, NetworkTraceStep.Path]=None, - data: T=None, - phases: PhaseCode=None, - traversed_ac_line_segment: AcLineSegment=None): + def _add_start_item( + self, start: Union[Terminal, NetworkTraceStep.Path] = None, 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 `NetworkTraceStep.Path`s for the start item - and adds it to the `Traversal` + 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` If `start` is a `NetworkTraceStep.Path`, [`phases`, `traversed_ac_line_segment`] will all be ignored. - :param start: The starting `Terminal` or `NetworkTraceStep.Path` to be added to the trace + :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 AcLineSegment that was just traversed + :param traversed_ac_line_segment: The :class:`AcLineSegment` that was just traversed :returns: This `NetworkTrace` instance """ @@ -273,21 +273,22 @@ def _add_start_item(self, async def run( self, - start: Union[ConductingEquipment, Terminal, NetworkTraceStep.Path]=None, - data: T=None, - phases: PhaseCode=None, - can_stop_on_start_item: bool=True + 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. """ @@ -298,82 +299,86 @@ async def run( 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 len(inspect.getfullargspec(condition).args) == 1: # Catches DSL Style lambda conditions from zepben.evolve.Conditions - return self.add_condition(condition(self.network_state_operators)) - return super().add_condition(condition) + 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 + 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. + - ``action_type`` is ``NetworkTraceActionType.ALL_STEPS`` the condition will be checked on all steps. + - ``action_type`` is ``NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT`` the condition will be checked on external steps. - However, if the `condition` is an instance of `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 + :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. + - ``action_type`` is ``NetworkTraceActionType.ALL_STEPS`` the condition will be checked on all steps. + - ``action_type`` is ``NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT`` the condition will be checked on external steps. - However, if the `condition` is an instance of `NetworkTraceStopCondition` the `NetworkTraceStopCondition.step_type` will be honoured. + 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 + :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) @@ -404,7 +409,7 @@ 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) @@ -422,18 +427,24 @@ def compute_data_with_action_type(compute_data: ComputeData[T], action_type: Can 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 961ff5bb4..15a7b327c 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 @@ -26,10 +26,10 @@ 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 @@ -37,16 +37,18 @@ 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 @@ -61,7 +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") @@ -69,7 +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,12 +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 @@ -103,10 +105,11 @@ 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 @@ -115,7 +118,7 @@ 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)""" + """Convenience method to access this ``NetworkTraceStep`` as a tuple of (self.path, self.data)""" return (self.path, self.data)[item] def __str__(self): diff --git a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py index 5cf26b936..23b5e0f31 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py +++ b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py @@ -5,7 +5,7 @@ import functools from logging import Logger from types import FunctionType -from typing import TypeVar, Union, cast, Optional, Type +from typing import TypeVar, Union, Optional, Type from zepben.evolve.services.network.tracing.traversal.queue_condition import QueueCondition from zepben.evolve.services.network.tracing.traversal.step_action import StepAction @@ -39,19 +39,20 @@ def wrap(self, obj: Wrappable, count: Optional[int] = None, allow_re_wrapping: b """ Wrap, in place, supported methods of the object passed in. - Supported object.methods: + Supported methods by object class:: - - StepAction.action - - StopCondition.should_stop - - QueueCondition + - :method:`StepAction.action` + - :method:`StopCondition.should_stop` + - :class:`QueueCondition` + - :method:`should_queue` + - :method:`should_queue_start_item` - - should_queue - - should_queue_start_item - - :param obj: Instantiated object representing a condition or action in a `Traversal` - :param count: (optional) Set the `count` in the log message + :param obj: Instantiated object representing a condition or action in a :class:`zepben.evolve.Traversal`. + :param count: (optional) Set the ``count`` in the log message. :param allow_re_wrapping: (optional) Replace the existing logging wrapper, if it exists. - :return: the object passed in for fluent use + :return: the object passed in for fluent use. + + :raises AttributeError: If wrapping the passed in object type is not supported. """ def get_logger_index(_clazz: Type[Wrappable], _attr: str) -> int: @@ -61,21 +62,23 @@ def get_logger_index(_clazz: Type[Wrappable], _attr: str) -> int: object aside from what it inherits from """ + # if we had a requested count number passed in, we can skip the auto-indexing logic. + if count is not None: + return count + # We need to check if the object has already been wrapped with logging so we can determine the - # count number we should use. - if hasattr(getattr(obj, _attr), '__wrapped__'): - # It has been, now we need to decide whether to use the previously assigned count by this class - # or - if it was wrapped with another class, we need to generate a new one. + # index number we should use. + if hasattr(obj, '__wrapped__'): + # Check to see if it's in our `_wrapped` registry - if so this class wrapped it. if obj in self._wrapped[clazz]: - # if it was wrapped by this class, return the original count + # if it was wrapped by this class, return the original index. (list index +1) return self._wrapped[clazz].index(obj) + 1 + # If the object has not been wrapped by this specific class instance, we generate a new index. if obj not in self._wrapped[clazz]: self._wrapped[clazz].append(obj) - - # if we had a requested count number passed in, use it - if count is not None: - return count + else: # This code path should NEVER be reached as we should never have an object at this point that is not in our `_wrapped` registry + raise IndexError(f'INTERNAL ERROR: {obj} not found in self._wrapped(\n{self._wrapped}\n)') return len(self._wrapped[clazz]) @@ -85,17 +88,19 @@ def wrap_attr(_attr: str) -> None: logging. :param _attr: Method/Function name. + :raises AttributeError: if the ``Wrappable`` cannot be rewrapped """ # wrapped methods will have `__wrapped__` set to the original method that was wrapped - if it exists on # the methods were interested in wrapping, the object has already been wrapped. We will re-wrap it, but - # only if we have been explicitly told its ok, otherwise we want to catch the bug. + # only if we have been explicitly told it's ok, otherwise we want to catch the bug. if (to_wrap := getattr(obj, _attr)) and hasattr(to_wrap, '__wrapped__'): if not allow_re_wrapping: raise AttributeError(f'Wrappable cannot be rewrapped without explicitly specifying you would like to replace the logging wrapper') to_wrap = getattr(to_wrap, '__wrapped__') setattr(obj, _attr, self._log_method_call(to_wrap, f'{self.description}: {_attr}({get_logger_index(clazz, _attr)})' + msg)) + setattr(obj, '__wrapped__', True) for clazz in (StepAction, StopCondition, QueueCondition): if isinstance(obj, clazz): @@ -107,12 +112,12 @@ def wrap_attr(_attr: str) -> None: def _log_method_call(self, func: FunctionType, log_string: str): """ - returns `func` wrapped with call to `self._logger` using `log_string` as the format + 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 `str.format()`, - as well as is `result` which is the result of the function itself + :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) 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 73f21cefc..affa15d5e 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/step_action.py +++ b/src/zepben/evolve/services/network/tracing/traversal/step_action.py @@ -3,7 +3,7 @@ # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. from abc import abstractmethod -from typing import TypeVar, Generic, Callable +from typing import TypeVar, Generic, Callable, final from zepben.evolve.services.network.tracing.traversal.context_value_computer import ContextValueComputer from zepben.evolve.services.network.tracing.traversal.step_context import StepContext @@ -24,12 +24,25 @@ class StepAction(Generic[T]): `T` The type of items being traversed. """ - def __init__(self, _func: StepActionFunc): + def __init__(self, _func: StepActionFunc = None): self._func = _func + 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``. :param item: The current item in the traversal. :param context: The context associated with the current traversal step. @@ -39,20 +52,30 @@ def apply(self, item: T, context: StepContext): 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. """ - def __init__(self, _func: StepActionFunc, key: str): - StepAction.__init__(self, _func) + def __init__(self, key: str, _func: StepActionFunc = None): + StepAction.__init__(self, _func or self._apply) ContextValueComputer.__init__(self, key) + @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() + @abstractmethod def compute_initial_value(self, item: T): - raise NotImplemented + raise NotImplementedError() @abstractmethod def compute_next_value(self, next_item: T, current_item: T, current_value): - raise NotImplemented + raise NotImplementedError() diff --git a/src/zepben/evolve/services/network/tracing/traversal/traversal.py b/src/zepben/evolve/services/network/tracing/traversal/traversal.py index 73093255f..5db8a0f98 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/traversal.py +++ b/src/zepben/evolve/services/network/tracing/traversal/traversal.py @@ -26,6 +26,7 @@ __all__ = ["Traversal"] from zepben.evolve.services.network.tracing.traversal.queue import TraversalQueue +from zepben.evolve.util import extra_kwargs_not_allowed T = TypeVar('T') U = TypeVar('U') @@ -36,6 +37,7 @@ QueueConditionTypes = Union[ShouldQueue, QueueCondition[T]] StopConditionTypes = Union[ShouldStop, StopCondition[T]] ConditionTypes = Union[QueueConditionTypes, StopConditionTypes] +StepActionTypes = Union[StepActionFunc, StepAction] class Traversal(Generic[T, D]): @@ -57,7 +59,7 @@ class Traversal(Generic[T, D]): 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. @@ -85,7 +87,6 @@ def queue(self) -> TraversalQueue[QT]: def branch_queue(self) -> Optional[TraversalQueue[QD]]: raise NotImplementedError - class BasicQueueType(QueueType[QT, QD]): """ Basic queue type that handles non-branching item queuing. @@ -108,7 +109,6 @@ def queue(self) -> TraversalQueue[QT]: def branch_queue(self) -> Optional[TraversalQueue[QD]]: return self._branch_queue - class BranchingQueueType(QueueType[QT, QD]): """ Branching queue type, supporting operations that may split into separate @@ -119,10 +119,12 @@ class BranchingQueueType(QueueType[QT, QD]): :param branch_queue_factory: Factory function to create the branch queue. """ - def __init__(self, - queue_next: Traversal.BranchingQueueNext[QT], - queue_factory: Callable[[], TraversalQueue[QT]], - branch_queue_factory: Callable[[], TraversalQueue[QD]]): + 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 @@ -137,7 +139,7 @@ def branch_queue(self) -> Optional[TraversalQueue[QD]]: name: str - def __init__(self, queue_type, parent: Optional[D]=None, debug_logger: Logger=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 @@ -145,7 +147,7 @@ def __init__(self, queue_type, parent: Optional[D]=None, debug_logger: Logger=No 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(queue_type) == Traversal.BranchingQueueType: - self.queue_next = lambda current, context: self._queue_next_branching(current, context, self._queue_type.queue_next) + self.queue_next = lambda current, context: self._queue_next_branching(current, context, self._queue_type.queue_next) self.queue: TraversalQueue[T] = queue_type.queue self.branch_queue: Optional[TraversalQueue[D]] = queue_type.branch_queue @@ -160,16 +162,6 @@ def __init__(self, queue_type, parent: Optional[D]=None, debug_logger: Logger=No self.compute_next_context_funs: Dict[str, ContextValueComputer[T]] = {} self.contexts: Dict[T, StepContext] = {} - def with_logger(self, logger: Logger) -> D: - """ - Method to set the debug_logger after Traversal.__init__() has ran - :param logger: the logger to use - :return: self - """ - - self._debug_logger = DebugLoggingWrapper(self.name, logger) - return self - def queue_next(self, current_item: T, context: StepContext): raise NotImplementedError @@ -188,9 +180,9 @@ 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. - `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 @@ -207,56 +199,62 @@ def create_new_this(self) -> D: debug logger through means you get duplicate wrappers that double, triple etc. log the debug messages. - Returns A new traversal instance. + :returns: A new traversal instance. """ raise NotImplementedError @singledispatchmethod - def add_condition(self, condition: ConditionTypes) -> D: + def add_condition(self, condition: ConditionTypes, **kwargs) -> D: """ Adds a traversal condition to the traversal. :param condition: The condition to add. + :keyword allow_re_wrapping: Allow rewrapping of :class:`StopConditions` with debug logging :return: this traversal instance. """ - if callable(condition): # Callable[[NetworkTraceStep[T], StepContext], None] + if callable(condition): # Callable[[NetworkTraceStep[T], StepContext], None] if len(inspect.getfullargspec(condition).args) == 2: - return self.add_stop_condition(condition) + return self.add_stop_condition(condition, **kwargs) elif len(inspect.getfullargspec(condition).args) == 4: - return self.add_queue_condition(condition) + return self.add_queue_condition(condition, **kwargs) 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: + def add_stop_condition(self, condition: StopConditionTypes, **kwargs) -> 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 + ``True``, the traversal will not call the callback to queue more items from the current item. :param condition: The stop condition to add. + :keyword allow_re_wrapping: Allow rewrapping of :class:`StopCondition`s with debug logging :return: this traversal instance. """ raise RuntimeError(f'Condition [{condition.__class__.__name__}] does not match expected: [StopCondition | StopConditionWithContextValue | Callable]') @add_stop_condition.register(Callable) - def _(self, condition: ShouldStop): - return self.add_stop_condition(StopCondition(condition)) + def _(self, condition: ShouldStop, **kwargs): + return self.add_stop_condition(StopCondition(condition), **kwargs) @add_stop_condition.register - def _(self, condition: StopCondition): + def _(self, condition: StopCondition, **kwargs): if self._debug_logger is not None: - self._debug_logger.wrap(condition) + self._debug_logger.wrap(condition, kwargs.pop('allow_re_wrapping', False)) + + extra_kwargs_not_allowed(kwargs, 'add_stop_condition') self.stop_conditions.append(condition) if isinstance(condition, StopConditionWithContextValue): @@ -283,27 +281,30 @@ def matches_any_stop_condition(self, item: T, context: StepContext) -> bool: @add_condition.register(QueueCondition) @singledispatchmethod - def add_queue_condition(self, condition: QueueConditionTypes) -> D: + def add_queue_condition(self, condition: QueueConditionTypes, **kwargs) -> D: """ Adds a queue condition to the traversal. Queue conditions determine whether an item should be queued for traversal. All registered queue conditions must return true for an item to be queued. :param condition: The queue condition to add. + :keyword allow_re_wrapping: Allow rewrapping of :class:`QueueCondition`s with debug logging :returns: The current traversal instance. """ raise RuntimeError(f'Condition [{condition.__class__.__name__}] does not match expected: [QueueCondition | QueueConditionWithContextValue | Callable]') @add_queue_condition.register(Callable) - def _(self, condition: ShouldQueue): - return self.add_queue_condition(QueueCondition(condition)) + def _(self, condition: ShouldQueue, **kwargs): + return self.add_queue_condition(QueueCondition(condition), **kwargs) @add_queue_condition.register - def _(self, condition: QueueCondition): + def _(self, condition: QueueCondition, **kwargs): if self._debug_logger is not None: - self._debug_logger.wrap(condition) + self._debug_logger.wrap(condition, kwargs.pop('allow_re_wrapping', False)) + + extra_kwargs_not_allowed(kwargs, 'add_queue_condition') self.queue_conditions.append(condition) if isinstance(condition, QueueConditionWithContextValue): @@ -322,74 +323,74 @@ def copy_queue_conditions(self, other: Traversal[T, D]) -> D: 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, **kwargs) -> D: """ Adds an action to be performed on each item in the traversal, including the starting items. :param action: The action to perform on each item. + :keyword allow_re_wrapping: Allow rewrapping of :class:`StepAction`s with debug logging :return: The current traversal instance. """ - if isinstance(action, StepAction): + raise RuntimeError(f'StepAction [{action.__class__.__name__}] does not match expected: [StepAction | StepActionWithContextValue | Callable]') - if self._debug_logger is not None: - self._debug_logger.wrap(action) + @add_step_action.register + def _(self, action: StepAction, **kwargs): + if self._debug_logger is not None: + self._debug_logger.wrap(action, kwargs.pop('allow_re_wrapping', False)) - self.step_actions.append(action) - if isinstance(action, StepActionWithContextValue): - self.compute_next_context_funs[action.key] = action - return self + extra_kwargs_not_allowed(kwargs, 'add_step_action') - elif callable(action): - return self.add_step_action(StepAction(action)) + self.step_actions.append(action) + if isinstance(action, StepActionWithContextValue): + self.compute_next_context_funs[action.key] = action + return self - raise RuntimeError(f'Condition [{action.__class__.__name__}] does not match expected: [StepAction | StepActionWithContextValue | Callable]') + @add_step_action.register(Callable) + def _(self, action: StepActionFunc, **kwargs): + return self.add_step_action(StepAction(action), **kwargs) - def if_not_stopping(self, action: Callable[[T, StepContext], None]) -> D: + @singledispatchmethod + def if_not_stopping(self, action: StepActionTypes, **kwargs) -> D: """ Adds an action to be performed on each item that does not match any stop condition. :param action: The action to perform on each non-stopping item. + :keyword allow_re_wrapping: Allow rewrapping of :class:`StepAction`s with debug logging :return: The current traversal instance. """ + raise RuntimeError(f'StepAction [{action}] does not match expected: [StepAction | StepActionWithContextValue | Callable]') - # TODO: at the moment were assuming a function being passed in, so we can turn it into - # a step action here, this prevents StepActionWithContextValue being passed in, however - # in future we want to allow passing step actions in here. the JVMSDK throws an error - # if you pass context aware step actions into here, though why cant we just send this - # on to `add_step_action`... + @if_not_stopping.register(Callable) + def _(self, action: StepActionFunc, **kwargs) -> D: + return self.add_step_action(lambda it, context: action(it, context) if not context.is_stopping else None, **kwargs) - step_action = StepAction(lambda it, context: action(it, context) if not context.is_stopping else None) + @if_not_stopping.register + def _(self, action: StepAction, **kwargs) -> D: + action.apply = lambda it, context: action._func(it, context) if not context.is_stopping else None + return self.add_step_action(action, **kwargs) - if self._debug_logger is not None: - self._debug_logger.wrap(step_action) - - self.step_actions.append(step_action) - return self - - - def if_stopping(self, action: Callable[[T, StepContext], None]) -> D: + @singledispatchmethod + def if_stopping(self, action: StepActionTypes, **kwargs) -> D: """ Adds an action to be performed on each item that matches a stop condition. :param action: The action to perform on each stopping item. + :keyword allow_re_wrapping: Allow rewrapping of :class:`StepActions`s with debug logging :return: The current traversal instance. """ + raise RuntimeError(f'StepAction [{action}] does not match expected: [StepAction | StepActionWithContextValue | Callable]') - # TODO: at the moment were assuming a function being passed in, so we can turn it into - # a step action here, this prevents StepActionWithContextValue being passed in, however - # in future we want to allow passing step actions in here. the JVMSDK throws an error - # if you pass context aware step actions into here, though why cant we just send this - # on to `add_step_action`... + @if_stopping.register(Callable) + def _(self, action: StepActionFunc, **kwargs) -> D: + return self.add_step_action(lambda it, context: action(it, context) if context.is_stopping else None, **kwargs) - step_action = StepAction(lambda it, context: action(it, context) if context.is_stopping else None) - - if self._debug_logger is not None: - self._debug_logger.wrap(step_action) - - self.step_actions.append(step_action) - return self + @if_stopping.register + def _(self, action: StepAction, **kwargs) -> D: + action.apply = lambda it, context: action._func(it, context) if context.is_stopping else None + return self.add_step_action(action, **kwargs) def copy_step_actions(self, other: Traversal[T, D]) -> D: """ @@ -420,7 +421,7 @@ def add_context_value_computer(self, computer: ContextValueComputer[T]) -> D: :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 @@ -451,7 +452,7 @@ 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: @@ -465,8 +466,7 @@ def add_start_item(self, item: T) -> D: 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. @@ -574,6 +574,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) @@ -581,6 +582,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) @@ -633,7 +635,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 @@ -641,7 +642,6 @@ def __init__(self, func): def accept(self, item: T, context: StepContext, queue_item: Callable[[T], bool]) -> bool: return self._func(item, context, queue_item) - class BranchingQueueNext(Generic[T]): def __init__(self, func): self._func = func diff --git a/src/zepben/evolve/util.py b/src/zepben/evolve/util.py index a9109ba81..06459cc7c 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 @@ -170,6 +183,11 @@ def __get__(self, cls, owner: T) -> T: return classmethod(self.fget).__get__(None, owner)() +def extra_kwargs_not_allowed(kwargs, function_name): + if kwargs: + raise TypeError(f"'{kwargs.pop()}' is an invalid keyword argument for {function_name}()") + + def datetime_to_timestamp(date_time: datetime) -> PBTimestamp: timestamp = PBTimestamp() timestamp.FromDatetime(date_time) diff --git a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py index e0cea2f1e..c73a27cdc 100644 --- a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py +++ b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py @@ -199,7 +199,7 @@ def test_rewrapping_stop_condition_throws_attribute_error_when_allow_re_wrapping with pytest.raises(AttributeError): logging_wrapper.wrap(condition) - # ensure rewrapping conditions already wrapped by another logger requires explicit approval + # Ensure rewrapping conditions already wrapped by another logger requires explicit approval logging_wrapper2 = DebugLoggingWrapper('my desc', self.logger) with pytest.raises(AttributeError): logging_wrapper2.wrap(condition) @@ -219,7 +219,7 @@ def test_rewrapping_stop_condition_works_when_allow_re_wrapping_is_true(self): # Make sure we didn't double add it. assert len(logging_wrapper._wrapped[StopCondition]) == 1 - # ensure rewrapping conditions already wrapped by another logger works when specified + # Ensure rewrapping conditions already wrapped by another logger works when specified logging_wrapper2 = DebugLoggingWrapper('my desc', self.logger) logging_wrapper2.wrap(condition, allow_re_wrapping=True) @@ -229,7 +229,7 @@ def test_adding_to_debug_logging_wrapper_increments_count_as_expected(self): condition = StopCondition(lambda item, context: True) logging_wrapper.wrap(condition) - # check count starts at 1, and double adding the same condition doesnt increment count + # Check count starts at 1, and double adding the same condition doesn't increment count with self._log_handler() as handler: condition.should_stop(False, False) assert handler.log_list.get() == f"root: my desc: should_stop(1)=True [item=False, context=False]" diff --git a/test/services/network/tracing/traversal/test_traversal.py b/test/services/network/tracing/traversal/test_traversal.py index 67dea8f61..1dd30aba3 100644 --- a/test/services/network/tracing/traversal/test_traversal.py +++ b/test/services/network/tracing/traversal/test_traversal.py @@ -8,7 +8,7 @@ import pytest -from zepben.evolve import StepContext, Traversal, TraversalQueue, ContextValueComputer, StepActionWithContextValue +from zepben.evolve import StepContext, Traversal, TraversalQueue, ContextValueComputer, StepActionWithContextValue, StepAction T = TypeVar('T') D = TypeVar('D') @@ -18,14 +18,15 @@ class TraversalTest(Traversal[T, D]): 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, - ): + 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 @@ -44,11 +45,12 @@ 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: @@ -56,13 +58,11 @@ def queue_next(item, _, queue_item): 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: @@ -76,7 +76,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, @@ -98,7 +98,8 @@ def step_action(item, _): await (_create_traversal() .add_condition(lambda item, _: item == 2) .add_step_action(step_action) - .run(1)) + .run(1) + ) assert self.last_num == 2 @@ -110,7 +111,8 @@ def step_action(item, _): 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 @@ -121,7 +123,8 @@ async def test_stop_conditions(self): 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 @@ -135,11 +138,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 @@ -148,11 +153,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 @@ -161,11 +168,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 @@ -177,7 +186,8 @@ def step_action(item, _): 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 @@ -186,11 +196,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 @@ -199,11 +211,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 @@ -211,33 +225,111 @@ 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] = {} + + class TestSAWCV(StepActionWithContextValue[int]): + def compute_next_value(self, next_item: int, current_item: int, current_value): + return f'{current_value} : {next_item + current_item}' + + def compute_initial_value(self, item: int): + 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 : 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] = {} + + class TestSAWCV(StepActionWithContextValue[int]): + def compute_next_value(self, next_item: int, current_item: int, current_value): + return f'{current_value} : {next_item + current_item}' + + def compute_initial_value(self, item: int): + 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 : 3 : 5' + + @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}' + def compute_initial_value(self, item: int): return f'{item}' @@ -245,7 +337,8 @@ def compute_initial_value(self, item: int): .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' @@ -253,14 +346,17 @@ def compute_initial_value(self, item: int): @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() @@ -272,37 +368,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) @@ -314,16 +416,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 @@ -351,45 +454,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] - - @pytest.mark.asyncio - async def test_must_use_add_step_action_for_context_aware_actions(self): - action = StepActionWithContextValue[int](lambda step, ctx: None, key='123') - _create_traversal().add_step_action(action) - - _create_traversal().if_stopping(action) - - _create_traversal().if_not_stopping(action) From 3cf1ee45a2145ccda097456274ad5e05f44afeef Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Mon, 9 Jun 2025 17:04:10 +1000 Subject: [PATCH 17/28] remove old redundant error and default arg Signed-off-by: Max Chesterfield --- .../services/network/tracing/networktrace/network_trace.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 e282d901d..14edd8b17 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py @@ -243,7 +243,7 @@ def _(self, start: NetworkTraceStep.Path, data: T, phases=None): return self def _add_start_item( - self, start: Union[Terminal, NetworkTraceStep.Path] = None, data: T = None, phases: PhaseCode = None, traversed_ac_line_segment: AcLineSegment = None + 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 @@ -259,9 +259,6 @@ def _add_start_item( :returns: This `NetworkTrace` instance """ - if start is None: - raise ValueError('path and start must not both be None.') - 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') From 9d821fe4efe1ea4ab59f24f76fecb188458f1a41 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Mon, 16 Jun 2025 11:11:04 +1000 Subject: [PATCH 18/28] rewrapping was a terrible idea, lets not do that Signed-off-by: Max Chesterfield --- .../tracing/traversal/debug_logging.py | 84 ++++++------- .../traversal/test_debug_logging_wrapper.py | 114 +++++------------- 2 files changed, 70 insertions(+), 128 deletions(-) diff --git a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py index 23b5e0f31..20c89ef11 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py +++ b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py @@ -2,6 +2,9 @@ # 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 @@ -13,12 +16,10 @@ T = TypeVar('T') -__all__ = ['DebugLoggingWrapper'] - Wrappable = Union[StepAction[T], QueueCondition[T], StopCondition[T]] -data = { +_data = { StepAction: [('apply', ' [item={args[0]}, context={args[1]}]')], StopCondition: [('should_stop', '={result} [item={args[0]}, context={args[1]}]')], QueueCondition: [ @@ -35,78 +36,79 @@ def __init__(self, description: str, logger: Logger): self._logger: Logger = logger self._wrapped = {StepAction: [], StopCondition: [], QueueCondition: []} - def wrap(self, obj: Wrappable, count: Optional[int] = None, allow_re_wrapping: bool = False): + def wrap(self, obj: Wrappable, count: Optional[int] = None): """ - Wrap, in place, supported methods of the object passed in. + Return a new object with debug logging wrappers applied to supported methods of the object. Supported methods by object class:: - - :method:`StepAction.action` - - :method:`StopCondition.should_stop` + - :meth:`StepAction.action` + - :meth:`StopCondition.should_stop` - :class:`QueueCondition` - - :method:`should_queue` - - :method:`should_queue_start_item` + - :meth:`should_queue` + - :meth:`should_queue_start_item` :param obj: Instantiated object representing a condition or action in a :class:`zepben.evolve.Traversal`. :param count: (optional) Set the ``count`` in the log message. - :param allow_re_wrapping: (optional) Replace the existing logging wrapper, if it exists. - :return: the object passed in for fluent use. + :return: new copy of the object passed in for fluent use. :raises AttributeError: If wrapping the passed in object type is not supported. """ - def get_logger_index(_clazz: Type[Wrappable], _attr: str) -> int: + # 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], _attr: str) -> 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 """ - # if we had a requested count number passed in, we can skip the auto-indexing logic. + # If we had a requested count number passed in, we can skip the auto-indexing logic. if count is not None: return count - # We need to check if the object has already been wrapped with logging so we can determine the - # index number we should use. - if hasattr(obj, '__wrapped__'): - # Check to see if it's in our `_wrapped` registry - if so this class wrapped it. - if obj in self._wrapped[clazz]: - # if it was wrapped by this class, return the original index. (list index +1) - return self._wrapped[clazz].index(obj) + 1 - - # If the object has not been wrapped by this specific class instance, we generate a new index. - if obj not in self._wrapped[clazz]: - self._wrapped[clazz].append(obj) - else: # This code path should NEVER be reached as we should never have an object at this point that is not in our `_wrapped` registry - raise IndexError(f'INTERNAL ERROR: {obj} not found in self._wrapped(\n{self._wrapped}\n)') + # We need to check if we have already wrapped another method on the object. + if hasattr(w_obj, '__wrapped__'): + # Ensure it's in our `_wrapped` registry - if so this is another method on the same object in the same `wrap` call. + if w_obj not in self._wrapped[clazz]: + # If this code path is reached, someone has done some wild internal hacking + raise AttributeError(f'Wrapped objects cannot be rewrapped, pass in the original object instead.') + # if it was wrapped by this class, return the original index. (list index +1) + return self._wrapped[clazz].index(w_obj) + 1 + + # If the object has not been wrapped we generate a new index. + if w_obj not in self._wrapped[clazz]: + self._wrapped[clazz].append(w_obj) + else: + # This code path should NEVER be reached as we should never have an object at this point that is not in our `_wrapped` registry + raise IndexError(f'INTERNAL ERROR: {w_obj} not found in self._wrapped(\n{self._wrapped}\n)') return len(self._wrapped[clazz]) - def wrap_attr(_attr: str) -> None: + def _wrap_attr(_attr: str) -> None: """ Replaces the specified attr with a wrapper around the same attr to inject logging. :param _attr: Method/Function name. - :raises AttributeError: if the ``Wrappable`` cannot be rewrapped + :raises AttributeError: if ``wrappable`` is already wrapped """ - # wrapped methods will have `__wrapped__` set to the original method that was wrapped - if it exists on - # the methods were interested in wrapping, the object has already been wrapped. We will re-wrap it, but - # only if we have been explicitly told it's ok, otherwise we want to catch the bug. - if (to_wrap := getattr(obj, _attr)) and hasattr(to_wrap, '__wrapped__'): - if not allow_re_wrapping: - raise AttributeError(f'Wrappable cannot be rewrapped without explicitly specifying you would like to replace the logging wrapper') - to_wrap = getattr(to_wrap, '__wrapped__') + # Wrapped classes will have __wrapped__ == True - if it exists on the obj passed in, the user is attempting to wrap an + # already wrapped object. This can lead to unexpected outcomes so we do not support it + if (to_wrap := getattr(w_obj, _attr)) and hasattr(to_wrap, '__wrapped__'): + raise AttributeError(f'Wrapped objects cannot be rewrapped, pass in the original object instead.') - setattr(obj, _attr, self._log_method_call(to_wrap, f'{self.description}: {_attr}({get_logger_index(clazz, _attr)})' + msg)) - setattr(obj, '__wrapped__', True) + setattr(w_obj, _attr, self._log_method_call(to_wrap, f'{self.description}: {_attr}({_get_logger_index(clazz, _attr)})' + msg)) + setattr(w_obj, '__wrapped__', True) for clazz in (StepAction, StopCondition, QueueCondition): - if isinstance(obj, clazz): - for attr, msg in data.get(clazz): - wrap_attr(attr) - return obj + if isinstance(w_obj, clazz): + for attr, msg in _data.get(clazz): + _wrap_attr(attr) + return w_obj else: raise AttributeError(f'{type(self).__name__} does not support wrapping {obj}') diff --git a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py index c73a27cdc..b94fb503e 100644 --- a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py +++ b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py @@ -56,21 +56,21 @@ def test_wrapped_object_is_original_object(self): stop_condition = StopCondition(lambda item, ctx: next(should_stop)) wrapped = self._wrap(stop_condition, 100) - assert wrapped is stop_condition + assert isinstance(wrapped, type(stop_condition)) assert isinstance(wrapped, StopCondition) assert not isinstance(wrapped, QueueCondition) assert not isinstance(wrapped, StepAction) queue_condition = QueueCondition(lambda nitem, nctx, item, ctx: next(should_stop)) wrapped = self._wrap(queue_condition, 20) - assert wrapped is queue_condition + assert isinstance(wrapped, type(queue_condition)) assert not isinstance(wrapped, StopCondition) assert isinstance(wrapped, QueueCondition) assert not isinstance(wrapped, StepAction) action = StepAction(lambda item, context: None) wrapped = self._wrap(action, 20) - assert wrapped is action + assert isinstance(wrapped, type(action)) assert not isinstance(wrapped, StopCondition) assert not isinstance(wrapped, QueueCondition) assert isinstance(wrapped, StepAction) @@ -92,7 +92,7 @@ def test_can_wrap_queue_conditions(self): condition = QueueCondition(lambda nitem, nctx, item, ctx: next(should_stop)) condition.should_queue_start_item = lambda item: next(should_stop) - self._wrap(condition, 50) + condition = self._wrap(condition, 50) with self._log_handler() as handler: assert condition.should_queue(self.item_1, self.context_1, self.item_2, self.context_2) @@ -128,28 +128,13 @@ def test_rewrapping_step_action_throws_attribute_error_when_allow_re_wrapping_is logging_wrapper = DebugLoggingWrapper('my desc', self.logger) action = StepAction(lambda item, context: None) - logging_wrapper.wrap(action) + wrapped_action = logging_wrapper.wrap(action) - assert isinstance(action, StepAction) - assert action in logging_wrapper._wrapped[StepAction] + assert isinstance(wrapped_action, StepAction) + assert wrapped_action in logging_wrapper._wrapped[StepAction] with pytest.raises(AttributeError): - logging_wrapper.wrap(action) - - def test_rewrapping_step_action_works_when_allow_re_wrapping_is_true(self): - logging_wrapper = DebugLoggingWrapper('my desc', self.logger) - - action = StepAction(lambda item, context: None) - logging_wrapper.wrap(action) - assert len(logging_wrapper._wrapped[StepAction]) == 1 - - assert isinstance(action, StepAction) - assert action in logging_wrapper._wrapped[StepAction] - - logging_wrapper.wrap(action, allow_re_wrapping=True) - - # Make sure we didn't double add it. - assert len(logging_wrapper._wrapped[StepAction]) == 1 + logging_wrapper.wrap(wrapped_action) def test_rewrapping_queue_condition_throws_attribute_error_when_allow_re_wrapping_is_false(self): logging_wrapper = DebugLoggingWrapper('my desc', self.logger) @@ -157,109 +142,64 @@ def test_rewrapping_queue_condition_throws_attribute_error_when_allow_re_wrappin should_stop = bool_generator() condition = QueueCondition(lambda nitem, nctx, item, ctx: next(should_stop)) - logging_wrapper.wrap(condition) + wrapped_condition = logging_wrapper.wrap(condition) - assert isinstance(condition, QueueCondition) - assert condition in logging_wrapper._wrapped[QueueCondition] + assert isinstance(wrapped_condition, QueueCondition) + assert wrapped_condition in logging_wrapper._wrapped[QueueCondition] with pytest.raises(AttributeError): - logging_wrapper.wrap(condition) - - def test_rewrapping_queue_condition_works_when_allow_re_wrapping_is_true(self): - logging_wrapper = DebugLoggingWrapper('my desc', self.logger) - - should_stop = bool_generator() - condition = QueueCondition(lambda nitem, nctx, item, ctx: next(should_stop)) - - assert condition.should_queue(False, False, False, False) - - logging_wrapper.wrap(condition) - - assert not condition.should_queue(False, False, False, False) - assert len(logging_wrapper._wrapped[QueueCondition]) == 1 - assert isinstance(condition, QueueCondition) - assert condition in logging_wrapper._wrapped[QueueCondition] - - logging_wrapper.wrap(condition, allow_re_wrapping=True) - - assert condition.should_queue(False, False, False, False) - - # Make sure we didn't double add it. - assert len(logging_wrapper._wrapped[QueueCondition]) == 1 + logging_wrapper.wrap(wrapped_condition) def test_rewrapping_stop_condition_throws_attribute_error_when_allow_re_wrapping_is_false(self): logging_wrapper = DebugLoggingWrapper('my desc', self.logger) condition = StopCondition(lambda item, context: True) - logging_wrapper.wrap(condition) + wrapped_condition = logging_wrapper.wrap(condition) - assert isinstance(condition, StopCondition) - assert condition in logging_wrapper._wrapped[StopCondition] + assert isinstance(wrapped_condition, StopCondition) + assert wrapped_condition in logging_wrapper._wrapped[StopCondition] with pytest.raises(AttributeError): - logging_wrapper.wrap(condition) + logging_wrapper.wrap(wrapped_condition) # Ensure rewrapping conditions already wrapped by another logger requires explicit approval logging_wrapper2 = DebugLoggingWrapper('my desc', self.logger) with pytest.raises(AttributeError): - logging_wrapper2.wrap(condition) - - def test_rewrapping_stop_condition_works_when_allow_re_wrapping_is_true(self): - logging_wrapper = DebugLoggingWrapper('my desc', self.logger) - - condition = StopCondition(lambda item, context: True) - logging_wrapper.wrap(condition) - assert len(logging_wrapper._wrapped[StopCondition]) == 1 - - assert isinstance(condition, StopCondition) - assert condition in logging_wrapper._wrapped[StopCondition] - - logging_wrapper.wrap(condition, allow_re_wrapping=True) - - # Make sure we didn't double add it. - assert len(logging_wrapper._wrapped[StopCondition]) == 1 - - # Ensure rewrapping conditions already wrapped by another logger works when specified - logging_wrapper2 = DebugLoggingWrapper('my desc', self.logger) - logging_wrapper2.wrap(condition, allow_re_wrapping=True) + logging_wrapper2.wrap(wrapped_condition) 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) - logging_wrapper.wrap(condition) + 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: - condition.should_stop(False, False) - assert handler.log_list.get() == f"root: my desc: should_stop(1)=True [item=False, context=False]" - - logging_wrapper.wrap(condition, allow_re_wrapping=True) - condition.should_stop(False, False) + 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) - logging_wrapper.wrap(condition2) + wrapped_condition2 = logging_wrapper.wrap(condition2) with self._log_handler() as handler: # check the new condition is marked as "2" - condition2.should_stop(False, False) + 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" - condition.should_stop(False, False) + 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 addind the already wrapped conditions to a new logger resets the count. + # check that adding the original condition to a new logger works, and resets the count. logging_wrapper2 = DebugLoggingWrapper('my desc', self.logger) - logging_wrapper2.wrap(condition, allow_re_wrapping=True) - logging_wrapper2.wrap(condition2, allow_re_wrapping=True) + wrapped_original_condition = logging_wrapper2.wrap(condition) + wrapped_original_condition2 = logging_wrapper2.wrap(condition2) with self._log_handler() as handler: - condition.should_stop(False, False) + 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" - condition2.should_stop(False, False) + wrapped_original_condition2.should_stop(False, False) assert handler.log_list.get() == f"root: my desc: should_stop(2)=True [item=False, context=False]" From a983dbd635275f891edea8e8fccdf60b7a4650a3 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Thu, 19 Jun 2025 13:55:44 +1000 Subject: [PATCH 19/28] fix some type hinting and docstrings Signed-off-by: Max Chesterfield --- .../network/tracing/networktrace/network_trace.py | 14 ++++++++------ .../network/tracing/networktrace/tracing.py | 6 +++--- 2 files changed, 11 insertions(+), 9 deletions(-) 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 14edd8b17..0347b981c 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py @@ -78,7 +78,7 @@ def __init__( network_state_operators: Type[NetworkStateOperators], queue_type: Union[Traversal.BasicQueueType, Traversal.BranchingQueueType], parent: 'NetworkTrace[T]' = None, - action_type: NetworkTraceActionType = None, + action_type: CanActionItem = None, debug_logger: Logger = None, name: str = None, ): @@ -339,12 +339,13 @@ def add_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 :class:`NetworkTraceQueueCondition` the ``NetworkTraceQueueCondition.step_type`` will be honoured. :param condition: The queue condition to add. + :param step_type: `NetworkTraceStepType` value. :keyword allow_re_wrapping: Allow rewrapping of :class:`QueueCondition`s with debug logging :returns: This :class:`NetworkTrace` instance """ @@ -361,12 +362,13 @@ def add_stop_condition(self, condition: StopConditionTypes, step_type: NetworkTr 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 :class:`NetworkTraceStopCondition` the ``NetworkTraceStopCondition.step_type`` will be honoured. :param condition: The stop condition to add. + :param step_type: `NetworkTraceStepType` value. :keyword allow_re_wrapping: Allow rewrapping of :class:`StopCondition`s with debug logging :returns: This :class:`NetworkTrace` instance """ @@ -410,7 +412,7 @@ def visit(self, terminal: Terminal, phases: FrozenSet[SinglePhaseKind]) -> bool: 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: diff --git a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py index eccbf68ed..c9d12063c 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py @@ -7,7 +7,7 @@ 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 @@ -19,7 +19,7 @@ class Tracing: @staticmethod def network_trace( network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, - action_step_type: NetworkTraceActionType=NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, + action_step_type: CanActionItem=NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, debug_logger: Logger=None, name: str='NetworkTrace', queue: TraversalQueue[NetworkTraceStep[T]]=TraversalQueue.depth_first(), @@ -51,7 +51,7 @@ def network_trace( @staticmethod def network_trace_branching( network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, - action_step_type: NetworkTraceActionType=NetworkTraceActionType.FIRST_STEP_ON_EQUIPMENT, + 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(), From 8921115c28c7c522926e7954555f63218ce4de5c Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Thu, 19 Jun 2025 14:28:20 +1000 Subject: [PATCH 20/28] remove count from debug logging, fix network builder Signed-off-by: Max Chesterfield --- .../tracing/traversal/debug_logging.py | 12 ++---- .../evolve/testing/test_network_builder.py | 12 +++--- .../network/tracing/phases/test_set_phases.py | 16 ------- .../traversal/test_debug_logging_wrapper.py | 43 +++++++++---------- .../tracing/traversal/test_traversal.py | 4 +- 5 files changed, 32 insertions(+), 55 deletions(-) diff --git a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py index 20c89ef11..25d762f6d 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py +++ b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py @@ -36,7 +36,7 @@ def __init__(self, description: str, logger: Logger): self._logger: Logger = logger self._wrapped = {StepAction: [], StopCondition: [], QueueCondition: []} - def wrap(self, obj: Wrappable, count: Optional[int] = None): + def wrap(self, obj: Wrappable): """ Return a new object with debug logging wrappers applied to supported methods of the object. @@ -65,10 +65,6 @@ def _get_logger_index(_clazz: Type[Wrappable], _attr: str) -> int: object aside from what it inherits from """ - # If we had a requested count number passed in, we can skip the auto-indexing logic. - if count is not None: - return count - # We need to check if we have already wrapped another method on the object. if hasattr(w_obj, '__wrapped__'): # Ensure it's in our `_wrapped` registry - if so this is another method on the same object in the same `wrap` call. @@ -87,7 +83,7 @@ def _get_logger_index(_clazz: Type[Wrappable], _attr: str) -> int: return len(self._wrapped[clazz]) - def _wrap_attr(_attr: str) -> None: + def _wrap_attr(_attr: str, _msg: str) -> None: """ Replaces the specified attr with a wrapper around the same attr to inject logging. @@ -101,13 +97,13 @@ def _wrap_attr(_attr: str) -> None: if (to_wrap := getattr(w_obj, _attr)) and hasattr(to_wrap, '__wrapped__'): raise AttributeError(f'Wrapped objects cannot be rewrapped, pass in the original object instead.') - setattr(w_obj, _attr, self._log_method_call(to_wrap, f'{self.description}: {_attr}({_get_logger_index(clazz, _attr)})' + msg)) + setattr(w_obj, _attr, self._log_method_call(to_wrap, f'{self.description}: {_attr}({_get_logger_index(clazz, _attr)})' + _msg)) setattr(w_obj, '__wrapped__', True) for clazz in (StepAction, StopCondition, QueueCondition): if isinstance(w_obj, clazz): for attr, msg in _data.get(clazz): - _wrap_attr(attr) + _wrap_attr(attr, msg) return w_obj else: raise AttributeError(f'{type(self).__name__} does not support wrapping {obj}') diff --git a/src/zepben/evolve/testing/test_network_builder.py b/src/zepben/evolve/testing/test_network_builder.py index 538496c1f..a3af3dc30 100644 --- a/src/zepben/evolve/testing/test_network_builder.py +++ b/src/zepben/evolve/testing/test_network_builder.py @@ -454,6 +454,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': """ @@ -461,6 +462,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 @@ -470,9 +472,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) - terminal = Terminal(mrid=f'{clamp.mrid}-t1') - self.network.add(terminal) - clamp.add_terminal(terminal) + self._add_terminal(clamp, 1 , nominal_phases) acls.add_clamp(clamp) action(clamp) @@ -485,6 +485,7 @@ 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': """ @@ -494,6 +495,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 @@ -504,9 +506,7 @@ def with_cut( cut = Cut(mrid=mrid or f'{acls.mrid}-cut{acls.num_cuts() + 1}', length_from_terminal_1=length_from_terminal_1) for i in [1, 2]: - t = Terminal(mrid=f'{cut.mrid}-t{i}') - self.network.add(t) - cut.add_terminal(t) + self._add_terminal(cut, i, nominal_phases) cut.set_normally_open(is_normally_open) if is_open is None: diff --git a/test/services/network/tracing/phases/test_set_phases.py b/test/services/network/tracing/phases/test_set_phases.py index ee2d00a8e..de7df6ae2 100644 --- a/test/services/network/tracing/phases/test_set_phases.py +++ b/test/services/network/tracing/phases/test_set_phases.py @@ -14,22 +14,6 @@ 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 - - def _create_network_trace(self, state_operators: NetworkStateOperators, partially_energised_transformers) -> 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 (super()._create_network_trace(state_operators, partially_energised_transformers)) \ - .add_step_action(log_step) - -SetPhases = LoggingSetPhases -""" @pytest.mark.asyncio @pytest.mark.parametrize('phase_swap_loop_network', [(False,)], indirect=True) diff --git a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py index b94fb503e..2f6b3fe34 100644 --- a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py +++ b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py @@ -48,51 +48,48 @@ def _log_handler(self): item_1 = (1, 1.1) item_2 = (2, 2.2) - def _wrap(self, condition, count=None): - return DebugLoggingWrapper('my desc', self.logger).wrap(condition, count) + 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, 100) - assert isinstance(wrapped, type(stop_condition)) + wrapped = self._wrap(stop_condition) + assert isinstance(wrapped, StopCondition) - assert not isinstance(wrapped, QueueCondition) - assert not isinstance(wrapped, StepAction) + assert not isinstance(wrapped, (QueueCondition, StepAction)) queue_condition = QueueCondition(lambda nitem, nctx, item, ctx: next(should_stop)) - wrapped = self._wrap(queue_condition, 20) - assert isinstance(wrapped, type(queue_condition)) - assert not isinstance(wrapped, StopCondition) + wrapped = self._wrap(queue_condition) + + assert not isinstance(wrapped, (StopCondition, StepAction)) assert isinstance(wrapped, QueueCondition) - assert not isinstance(wrapped, StepAction) action = StepAction(lambda item, context: None) - wrapped = self._wrap(action, 20) - assert isinstance(wrapped, type(action)) - assert not isinstance(wrapped, StopCondition) - assert not isinstance(wrapped, QueueCondition) + 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)), 100) + 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(100)=True [item={self.item_1}, context={self.context_1}]" - assert handler.log_list.get() == f"root: my desc: should_stop(100)=False [item={self.item_2}, context={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, 50) + 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) @@ -104,18 +101,18 @@ def test_can_wrap_queue_conditions(self): assert condition.should_queue_start_item(self.item_2) assert handler.log_list.get() == ( - f"root: my desc: should_queue(50)=True [" + 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(50)=False [" + 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(50)=False [item={self.item_1}]" - assert handler.log_list.get() == f"root: my desc: should_queue_start_item(50)=True [item={self.item_2}]" + 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), 1) + action = self._wrap(StepAction(lambda item, context: None)) with self._log_handler() as handler: action.apply(self.item_1, self.context_1) diff --git a/test/services/network/tracing/traversal/test_traversal.py b/test/services/network/tracing/traversal/test_traversal.py index 1dd30aba3..9deea79e7 100644 --- a/test/services/network/tracing/traversal/test_traversal.py +++ b/test/services/network/tracing/traversal/test_traversal.py @@ -276,7 +276,7 @@ async def test_if_not_stopping_helper_accepts_step_action_with_context_value_and class TestSAWCV(StepActionWithContextValue[int]): def compute_next_value(self, next_item: int, current_item: int, current_value): - return f'{current_value} : {next_item + current_item}' + return f'data={current_value} : (next_item={next_item}, current_item={current_item})' def compute_initial_value(self, item: int): return f'{item}' @@ -293,7 +293,7 @@ def step_action(item, ctx: StepContext): assert len(data_capture) == 2 assert data_capture[1] == '1' - assert data_capture[2] == '1 : 3' + assert data_capture[2] == 'data=1 : (next_item=2, current_item=1)' @pytest.mark.asyncio async def test_if_stopping_helper_accepts_step_action_with_context_value_and_context_is_computed(self): From 5167172fbe46454ea1d1b8f38456f4bb6b08d72d Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Thu, 19 Jun 2025 15:21:58 +1000 Subject: [PATCH 21/28] simplify code and remove unrequired exceptions, params, etc Signed-off-by: Max Chesterfield --- .../tracing/traversal/debug_logging.py | 42 +++++++------------ .../traversal/test_debug_logging_wrapper.py | 3 -- 2 files changed, 16 insertions(+), 29 deletions(-) diff --git a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py index 25d762f6d..cdd28c5ce 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py +++ b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py @@ -34,7 +34,11 @@ class DebugLoggingWrapper: def __init__(self, description: str, logger: Logger): self.description: str = description self._logger: Logger = logger - self._wrapped = {StepAction: [], StopCondition: [], QueueCondition: []} + self._wrapped = { + StepAction: 0, + StopCondition: 0, + QueueCondition: 0 + } def wrap(self, obj: Wrappable): """ @@ -49,7 +53,6 @@ def wrap(self, obj: Wrappable): - :meth:`should_queue_start_item` :param obj: Instantiated object representing a condition or action in a :class:`zepben.evolve.Traversal`. - :param count: (optional) Set the ``count`` in the log message. :return: new copy of the object passed in for fluent use. :raises AttributeError: If wrapping the passed in object type is not supported. @@ -58,32 +61,18 @@ def wrap(self, obj: Wrappable): # 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], _attr: str) -> int: + 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 - """ - - # We need to check if we have already wrapped another method on the object. - if hasattr(w_obj, '__wrapped__'): - # Ensure it's in our `_wrapped` registry - if so this is another method on the same object in the same `wrap` call. - if w_obj not in self._wrapped[clazz]: - # If this code path is reached, someone has done some wild internal hacking - raise AttributeError(f'Wrapped objects cannot be rewrapped, pass in the original object instead.') - # if it was wrapped by this class, return the original index. (list index +1) - return self._wrapped[clazz].index(w_obj) + 1 + object aside from what it inherits from. - # If the object has not been wrapped we generate a new index. - if w_obj not in self._wrapped[clazz]: - self._wrapped[clazz].append(w_obj) - else: - # This code path should NEVER be reached as we should never have an object at this point that is not in our `_wrapped` registry - raise IndexError(f'INTERNAL ERROR: {w_obj} not found in self._wrapped(\n{self._wrapped}\n)') - - return len(self._wrapped[clazz]) + """ + + self._wrapped[clazz] += 1 + return self._wrapped[clazz] - def _wrap_attr(_attr: str, _msg: str) -> None: + def _wrap_attr(_index: int, _attr: str, _msg: str) -> None: """ Replaces the specified attr with a wrapper around the same attr to inject logging. @@ -97,16 +86,17 @@ def _wrap_attr(_attr: str, _msg: str) -> None: if (to_wrap := getattr(w_obj, _attr)) and hasattr(to_wrap, '__wrapped__'): raise AttributeError(f'Wrapped objects cannot be rewrapped, pass in the original object instead.') - setattr(w_obj, _attr, self._log_method_call(to_wrap, f'{self.description}: {_attr}({_get_logger_index(clazz, _attr)})' + _msg)) + setattr(w_obj, _attr, self._log_method_call(to_wrap, f'{self.description}: {_attr}({_index})' + _msg)) setattr(w_obj, '__wrapped__', True) 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(attr, msg) + _wrap_attr(index, attr, msg) return w_obj else: - raise AttributeError(f'{type(self).__name__} does not support wrapping {obj}') + raise NotImplementedError(f'{type(self).__name__} does not support wrapping {obj}') def _log_method_call(self, func: FunctionType, log_string: str): """ diff --git a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py index 2f6b3fe34..b9adf942c 100644 --- a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py +++ b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py @@ -128,7 +128,6 @@ def test_rewrapping_step_action_throws_attribute_error_when_allow_re_wrapping_is wrapped_action = logging_wrapper.wrap(action) assert isinstance(wrapped_action, StepAction) - assert wrapped_action in logging_wrapper._wrapped[StepAction] with pytest.raises(AttributeError): logging_wrapper.wrap(wrapped_action) @@ -142,7 +141,6 @@ def test_rewrapping_queue_condition_throws_attribute_error_when_allow_re_wrappin wrapped_condition = logging_wrapper.wrap(condition) assert isinstance(wrapped_condition, QueueCondition) - assert wrapped_condition in logging_wrapper._wrapped[QueueCondition] with pytest.raises(AttributeError): logging_wrapper.wrap(wrapped_condition) @@ -154,7 +152,6 @@ def test_rewrapping_stop_condition_throws_attribute_error_when_allow_re_wrapping wrapped_condition = logging_wrapper.wrap(condition) assert isinstance(wrapped_condition, StopCondition) - assert wrapped_condition in logging_wrapper._wrapped[StopCondition] with pytest.raises(AttributeError): logging_wrapper.wrap(wrapped_condition) From 6a3000e10f44e5225d9c87a8066cd693a5f4f915 Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Thu, 19 Jun 2025 16:05:02 +1000 Subject: [PATCH 22/28] allow renaming of wrapped attrs Signed-off-by: Max Chesterfield --- .../tracing/traversal/debug_logging.py | 21 +++++++++++++------ .../traversal/test_debug_logging_wrapper.py | 4 ++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py index cdd28c5ce..d4cb6cf83 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py +++ b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py @@ -8,7 +8,7 @@ import functools from logging import Logger from types import FunctionType -from typing import TypeVar, Union, Optional, Type +from typing import TypeVar, Union, Optional, Type, TypedDict, 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 @@ -19,8 +19,13 @@ Wrappable = Union[StepAction[T], QueueCondition[T], StopCondition[T]] -_data = { - StepAction: [('apply', ' [item={args[0]}, context={args[1]}]')], +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]}]'), @@ -68,7 +73,7 @@ def _get_logger_index(_clazz: Type[Wrappable]) -> int: object aside from what it inherits from. """ - + self._wrapped[clazz] += 1 return self._wrapped[clazz] @@ -80,13 +85,17 @@ def _wrap_attr(_index: int, _attr: str, _msg: str) -> None: :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 # Wrapped classes will have __wrapped__ == True - if it exists on the obj passed in, the user is attempting to wrap an # already wrapped object. This can lead to unexpected outcomes so we do not support it - if (to_wrap := getattr(w_obj, _attr)) and hasattr(to_wrap, '__wrapped__'): + if (to_wrap := getattr(w_obj, _attr_name)) and hasattr(to_wrap, '__wrapped__'): raise AttributeError(f'Wrapped objects cannot be rewrapped, pass in the original object instead.') - setattr(w_obj, _attr, self._log_method_call(to_wrap, f'{self.description}: {_attr}({_index})' + _msg)) + setattr(w_obj, _attr_name, self._log_method_call(to_wrap, f'{self.description}: {_log_attr_name}({_index})' + _msg)) setattr(w_obj, '__wrapped__', True) for clazz in (StepAction, StopCondition, QueueCondition): diff --git a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py index b9adf942c..86e2fa048 100644 --- a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py +++ b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py @@ -118,8 +118,8 @@ def test_can_wrap_step_actions(self): 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: apply(1) [item={self.item_1}, context={self.context_1}]" - assert handler.log_list.get() == f"root: my desc: apply(1) [item={self.item_2}, context={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_rewrapping_step_action_throws_attribute_error_when_allow_re_wrapping_is_false(self): logging_wrapper = DebugLoggingWrapper('my desc', self.logger) From 9914e7a43fe5f2ff9cf709eb9bf1738016497caa Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Thu, 19 Jun 2025 16:07:29 +1000 Subject: [PATCH 23/28] rename tests Signed-off-by: Max Chesterfield --- .../network/tracing/traversal/test_debug_logging_wrapper.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py index 86e2fa048..36cee1173 100644 --- a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py +++ b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py @@ -121,7 +121,7 @@ def test_can_wrap_step_actions(self): 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_rewrapping_step_action_throws_attribute_error_when_allow_re_wrapping_is_false(self): + def test_rewrapping_step_action_throws_attribute_error(self): logging_wrapper = DebugLoggingWrapper('my desc', self.logger) action = StepAction(lambda item, context: None) @@ -132,7 +132,7 @@ def test_rewrapping_step_action_throws_attribute_error_when_allow_re_wrapping_is with pytest.raises(AttributeError): logging_wrapper.wrap(wrapped_action) - def test_rewrapping_queue_condition_throws_attribute_error_when_allow_re_wrapping_is_false(self): + def test_rewrapping_queue_condition_throws_attribute_error(self): logging_wrapper = DebugLoggingWrapper('my desc', self.logger) should_stop = bool_generator() @@ -145,7 +145,7 @@ def test_rewrapping_queue_condition_throws_attribute_error_when_allow_re_wrappin with pytest.raises(AttributeError): logging_wrapper.wrap(wrapped_condition) - def test_rewrapping_stop_condition_throws_attribute_error_when_allow_re_wrapping_is_false(self): + def test_rewrapping_stop_condition_throws_attribute_error(self): logging_wrapper = DebugLoggingWrapper('my desc', self.logger) condition = StopCondition(lambda item, context: True) From fb360cb1c23ca2e0c993f553e8eba0777794f6df Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Thu, 19 Jun 2025 20:31:58 +1000 Subject: [PATCH 24/28] fixed bad conflict resolutions Signed-off-by: Max Chesterfield --- changelog.md | 2 -- .../services/network/tracing/networktrace/network_trace_step.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/changelog.md b/changelog.md index 246e8afdd..372d9f69a 100644 --- a/changelog.md +++ b/changelog.md @@ -27,7 +27,6 @@ * 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. * Added support to `TestNetworkBuilder` for: @@ -42,7 +41,6 @@ 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` * `AssignToFeeder` and `AssignToLvFeeder` will no longer trace from start terminals that belong to open switches 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 15a7b327c..de5e697dd 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 From 1d2f9cc85e009da1b8d1cabab99e0f63ab1eda4f Mon Sep 17 00:00:00 2001 From: Anthony Charlton Date: Mon, 23 Jun 2025 18:18:48 +1000 Subject: [PATCH 25/28] Added `StepAction` test to highlight issue with `apply` and `_apply` Signed-off-by: Anthony Charlton --- .../tracing/traversal/test_step_action.py | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 test/services/network/tracing/traversal/test_step_action.py 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)] From 147e29e5501244a2e4e8f1d7c33580c9ab1bb47c Mon Sep 17 00:00:00 2001 From: Max Chesterfield Date: Thu, 26 Jun 2025 15:53:54 +1000 Subject: [PATCH 26/28] PR Changes Signed-off-by: Max Chesterfield --- .../network/tracing/phases/set_phases.py | 27 ++++--- .../tracing/traversal/debug_logging.py | 7 +- .../network/tracing/traversal/step_action.py | 25 +++---- .../network/tracing/traversal/traversal.py | 67 +++++++---------- src/zepben/evolve/util.py | 5 -- .../network/tracing/phases/test_set_phases.py | 72 +++++++++++-------- .../traversal/test_debug_logging_wrapper.py | 60 ++++++---------- .../tracing/traversal/test_traversal.py | 26 +++++-- 8 files changed, 137 insertions(+), 152 deletions(-) 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 6ce2c87c7..965682369 100644 --- a/src/zepben/evolve/services/network/tracing/phases/set_phases.py +++ b/src/zepben/evolve/services/network/tracing/phases/set_phases.py @@ -90,7 +90,7 @@ def _terminals_from_network(): async def _( self, start_terminal: Terminal, - phases: Union[PhaseCode, List[SinglePhaseKind]]=None, + phases: Union[PhaseCode, List[SinglePhaseKind], Set[SinglePhaseKind]]=None, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, seed_terminal: Terminal=None): """ @@ -144,11 +144,8 @@ def spread_phases( :param network_state_operators: The `NetworkStateOperators` to be used when setting phases. """ - if phases is None: - 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, phases) - self._flow_phases(network_state_operators, from_terminal, to_terminal, paths) + 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]): @@ -356,14 +353,15 @@ def _flow_transformer_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 path in paths: - if path.from_phase == SinglePhaseKind.NONE: - self._try_add_phase(from_terminal, from_phases, to_terminal, to_phases, path.to_phase, allow_suspect_flow, - lambda: updated_phases.append(True)) - else: - self._try_set_phase(from_phases[path.from_phase], from_terminal, from_phases, path.from_phase, - to_terminal, to_phases, path.to_phase, 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) @@ -399,8 +397,7 @@ def _try_set_phase( on_success: Callable[[], None]): try: - if phase != SinglePhaseKind.NONE: - to_phases[to_] = phase + 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() diff --git a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py index d4cb6cf83..2cf45aab9 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py +++ b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py @@ -85,18 +85,15 @@ def _wrap_attr(_index: int, _attr: str, _msg: str) -> None: :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 - # Wrapped classes will have __wrapped__ == True - if it exists on the obj passed in, the user is attempting to wrap an - # already wrapped object. This can lead to unexpected outcomes so we do not support it - if (to_wrap := getattr(w_obj, _attr_name)) and hasattr(to_wrap, '__wrapped__'): - raise AttributeError(f'Wrapped objects cannot be rewrapped, pass in the original object instead.') + 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)) - setattr(w_obj, '__wrapped__', True) for clazz in (StepAction, StopCondition, QueueCondition): if isinstance(w_obj, clazz): 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 affa15d5e..fa24beeda 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/step_action.py +++ b/src/zepben/evolve/services/network/tracing/traversal/step_action.py @@ -25,7 +25,7 @@ class StepAction(Generic[T]): """ def __init__(self, _func: StepActionFunc = None): - self._func = _func + self._func = _func or self._apply def __init_subclass__(cls): """ @@ -50,6 +50,17 @@ def apply(self, item: T, context: StepContext): return self._func(item, context) + @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 :class:`StepContext`. @@ -59,19 +70,9 @@ class StepActionWithContextValue(StepAction[T], ContextValueComputer[T]): """ def __init__(self, key: str, _func: StepActionFunc = None): - StepAction.__init__(self, _func or self._apply) + StepAction.__init__(self, _func) ContextValueComputer.__init__(self, key) - @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() - @abstractmethod def compute_initial_value(self, item: T): raise NotImplementedError() diff --git a/src/zepben/evolve/services/network/tracing/traversal/traversal.py b/src/zepben/evolve/services/network/tracing/traversal/traversal.py index 5db8a0f98..406f8140e 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/traversal.py +++ b/src/zepben/evolve/services/network/tracing/traversal/traversal.py @@ -26,7 +26,6 @@ __all__ = ["Traversal"] from zepben.evolve.services.network.tracing.traversal.queue import TraversalQueue -from zepben.evolve.util import extra_kwargs_not_allowed T = TypeVar('T') U = TypeVar('U') @@ -205,21 +204,20 @@ def create_new_this(self) -> D: raise NotImplementedError @singledispatchmethod - def add_condition(self, condition: ConditionTypes, **kwargs) -> D: + def add_condition(self, condition: ConditionTypes) -> D: """ Adds a traversal condition to the traversal. :param condition: The condition to add. - :keyword allow_re_wrapping: Allow rewrapping of :class:`StopConditions` with debug logging :return: this traversal instance. """ if callable(condition): # Callable[[NetworkTraceStep[T], StepContext], None] if len(inspect.getfullargspec(condition).args) == 2: - return self.add_stop_condition(condition, **kwargs) + return self.add_stop_condition(condition) elif len(inspect.getfullargspec(condition).args) == 4: - return self.add_queue_condition(condition, **kwargs) + 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)') @@ -231,30 +229,27 @@ def add_condition(self, condition: ConditionTypes, **kwargs) -> D: @singledispatchmethod @add_condition.register(StopCondition) - def add_stop_condition(self, condition: StopConditionTypes, **kwargs) -> D: + 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. :param condition: The stop condition to add. - :keyword allow_re_wrapping: Allow rewrapping of :class:`StopCondition`s with debug logging :return: this traversal instance. """ raise RuntimeError(f'Condition [{condition.__class__.__name__}] does not match expected: [StopCondition | StopConditionWithContextValue | Callable]') @add_stop_condition.register(Callable) - def _(self, condition: ShouldStop, **kwargs): - return self.add_stop_condition(StopCondition(condition), **kwargs) + def _(self, condition: ShouldStop): + return self.add_stop_condition(StopCondition(condition)) @add_stop_condition.register - def _(self, condition: StopCondition, **kwargs): + def _(self, condition: StopCondition): if self._debug_logger is not None: - self._debug_logger.wrap(condition, kwargs.pop('allow_re_wrapping', False)) - - extra_kwargs_not_allowed(kwargs, 'add_stop_condition') + self._debug_logger.wrap(condition) self.stop_conditions.append(condition) if isinstance(condition, StopConditionWithContextValue): @@ -281,30 +276,27 @@ def matches_any_stop_condition(self, item: T, context: StepContext) -> bool: @add_condition.register(QueueCondition) @singledispatchmethod - def add_queue_condition(self, condition: QueueConditionTypes, **kwargs) -> D: + def add_queue_condition(self, condition: QueueConditionTypes) -> D: """ Adds a queue condition to the traversal. Queue conditions determine whether an item should be queued for traversal. All registered queue conditions must return true for an item to be queued. :param condition: The queue condition to add. - :keyword allow_re_wrapping: Allow rewrapping of :class:`QueueCondition`s with debug logging :returns: The current traversal instance. """ raise RuntimeError(f'Condition [{condition.__class__.__name__}] does not match expected: [QueueCondition | QueueConditionWithContextValue | Callable]') @add_queue_condition.register(Callable) - def _(self, condition: ShouldQueue, **kwargs): - return self.add_queue_condition(QueueCondition(condition), **kwargs) + def _(self, condition: ShouldQueue): + return self.add_queue_condition(QueueCondition(condition)) @add_queue_condition.register - def _(self, condition: QueueCondition, **kwargs): + def _(self, condition: QueueCondition): if self._debug_logger is not None: - self._debug_logger.wrap(condition, kwargs.pop('allow_re_wrapping', False)) - - extra_kwargs_not_allowed(kwargs, 'add_queue_condition') + self._debug_logger.wrap(condition) self.queue_conditions.append(condition) if isinstance(condition, QueueConditionWithContextValue): @@ -324,24 +316,21 @@ def copy_queue_conditions(self, other: Traversal[T, D]) -> D: return self @singledispatchmethod - def add_step_action(self, action: StepActionTypes, **kwargs) -> D: + def add_step_action(self, action: StepActionTypes) -> D: """ Adds an action to be performed on each item in the traversal, including the starting items. :param action: The action to perform on each item. - :keyword allow_re_wrapping: Allow rewrapping of :class:`StepAction`s with debug logging :return: The current traversal instance. """ raise RuntimeError(f'StepAction [{action.__class__.__name__}] does not match expected: [StepAction | StepActionWithContextValue | Callable]') @add_step_action.register - def _(self, action: StepAction, **kwargs): + def _(self, action: StepAction): if self._debug_logger is not None: - self._debug_logger.wrap(action, kwargs.pop('allow_re_wrapping', False)) - - extra_kwargs_not_allowed(kwargs, 'add_step_action') + self._debug_logger.wrap(action) self.step_actions.append(action) if isinstance(action, StepActionWithContextValue): @@ -350,47 +339,45 @@ def _(self, action: StepAction, **kwargs): @add_step_action.register(Callable) def _(self, action: StepActionFunc, **kwargs): - return self.add_step_action(StepAction(action), **kwargs) + return self.add_step_action(StepAction(action)) @singledispatchmethod - def if_not_stopping(self, action: StepActionTypes, **kwargs) -> D: + def if_not_stopping(self, action: StepActionTypes) -> D: """ Adds an action to be performed on each item that does not match any stop condition. :param action: The action to perform on each non-stopping item. - :keyword allow_re_wrapping: Allow rewrapping of :class:`StepAction`s with debug logging :return: The current traversal instance. """ raise RuntimeError(f'StepAction [{action}] does not match expected: [StepAction | StepActionWithContextValue | Callable]') @if_not_stopping.register(Callable) - def _(self, action: StepActionFunc, **kwargs) -> D: - return self.add_step_action(lambda it, context: action(it, context) if not context.is_stopping else None, **kwargs) + def _(self, action: StepActionFunc) -> D: + return self.add_step_action(lambda it, context: action(it, context) if not context.is_stopping else None) @if_not_stopping.register - def _(self, action: StepAction, **kwargs) -> D: + 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, **kwargs) + return self.add_step_action(action) @singledispatchmethod - def if_stopping(self, action: StepActionTypes, **kwargs) -> D: + def if_stopping(self, action: StepActionTypes) -> D: """ Adds an action to be performed on each item that matches a stop condition. :param action: The action to perform on each stopping item. - :keyword allow_re_wrapping: Allow rewrapping of :class:`StepActions`s with debug logging :return: The current traversal instance. """ raise RuntimeError(f'StepAction [{action}] does not match expected: [StepAction | StepActionWithContextValue | Callable]') @if_stopping.register(Callable) - def _(self, action: StepActionFunc, **kwargs) -> D: - return self.add_step_action(lambda it, context: action(it, context) if context.is_stopping else None, **kwargs) + 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, **kwargs) -> D: + 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, **kwargs) + return self.add_step_action(action) def copy_step_actions(self, other: Traversal[T, D]) -> D: """ diff --git a/src/zepben/evolve/util.py b/src/zepben/evolve/util.py index 06459cc7c..b80592a40 100644 --- a/src/zepben/evolve/util.py +++ b/src/zepben/evolve/util.py @@ -183,11 +183,6 @@ def __get__(self, cls, owner: T) -> T: return classmethod(self.fget).__get__(None, owner)() -def extra_kwargs_not_allowed(kwargs, function_name): - if kwargs: - raise TypeError(f"'{kwargs.pop()}' is an invalid keyword argument for {function_name}()") - - def datetime_to_timestamp(date_time: datetime) -> PBTimestamp: timestamp = PBTimestamp() timestamp.FromDatetime(date_time) diff --git a/test/services/network/tracing/phases/test_set_phases.py b/test/services/network/tracing/phases/test_set_phases.py index de7df6ae2..799ba1048 100644 --- a/test/services/network/tracing/phases/test_set_phases.py +++ b/test/services/network/tracing/phases/test_set_phases.py @@ -254,42 +254,56 @@ async def test_applies_unknown_phases_through_transformers(): @pytest.mark.asyncio async def test_energises_transformer_phases_straight(): # Without neutral. - for phase_code in (PhaseCode.ABC, PhaseCode.AB, PhaseCode.BC, PhaseCode.AC): - await _validate_tx_phases(*[phase_code]*5) + 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) - for phase_code in (PhaseCode.AB, PhaseCode.BC, PhaseCode.AC): - await _validate_tx_phases(phase_code, phase_code, PhaseCode.XY, phase_code, phase_code) + 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) - for phase_code in (PhaseCode.AB, PhaseCode.BC, PhaseCode.AC): - await _validate_tx_phases(phase_code, PhaseCode.XY, PhaseCode.XY, phase_code, phase_code) + 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) - for phase_code in (PhaseCode.A, PhaseCode.B, PhaseCode.C): - await _validate_tx_phases(*[phase_code]*5) + await _validate_tx_phases(*[PhaseCode.A] * 5) + await _validate_tx_phases(*[PhaseCode.B] * 5) + await _validate_tx_phases(*[PhaseCode.C] * 5) - for phase_code in (PhaseCode.A, PhaseCode.B, PhaseCode.C): - await _validate_tx_phases(phase_code, phase_code, PhaseCode.X, phase_code, phase_code) + 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) - for phase_code in (PhaseCode.A, PhaseCode.B, PhaseCode.C): - await _validate_tx_phases(phase_code, PhaseCode.X, PhaseCode.X, phase_code, phase_code) + 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. - for phase_code in (PhaseCode.ABC, PhaseCode.AB, PhaseCode.BC, PhaseCode.AC): - await _validate_tx_phases(phase_code, phase_code, phase_code + PhaseCode.N, phase_code, phase_code + PhaseCode.N) - - for phase_code in (PhaseCode.AB, PhaseCode.BC, PhaseCode.AC): - await _validate_tx_phases(phase_code, phase_code, PhaseCode.XYN, phase_code, phase_code + PhaseCode.N) - - for phase_code in (PhaseCode.AB, PhaseCode.BC, PhaseCode.AC): - await _validate_tx_phases(phase_code, PhaseCode.XY, PhaseCode.XYN, phase_code, phase_code + PhaseCode.N) - - for phase_code in (PhaseCode.A, PhaseCode.B, PhaseCode.C): - await _validate_tx_phases(phase_code, phase_code, phase_code + PhaseCode.N, phase_code, phase_code + PhaseCode.N) - - for phase_code in (PhaseCode.A, PhaseCode.B, PhaseCode.C): - await _validate_tx_phases(phase_code, phase_code, PhaseCode.XN, phase_code, phase_code + PhaseCode.N) - - for phase_code in (PhaseCode.A, PhaseCode.B, PhaseCode.C): - await _validate_tx_phases(phase_code, PhaseCode.X, PhaseCode.XN, phase_code, phase_code + PhaseCode.N) + await _validate_tx_phases(PhaseCode.ABC, PhaseCode.ABC, PhaseCode.ABC + PhaseCode.N, PhaseCode.ABC, PhaseCode.ABC + PhaseCode.N) + await _validate_tx_phases(PhaseCode.AB, PhaseCode.AB, PhaseCode.AB + PhaseCode.N, PhaseCode.AB, PhaseCode.AB + PhaseCode.N) + await _validate_tx_phases(PhaseCode.BC, PhaseCode.BC, PhaseCode.BC + PhaseCode.N, PhaseCode.BC, PhaseCode.BC + PhaseCode.N) + await _validate_tx_phases(PhaseCode.AC, PhaseCode.AC, PhaseCode.AC + PhaseCode.N, PhaseCode.AC, PhaseCode.AC + PhaseCode.N) + + await _validate_tx_phases(PhaseCode.AB, PhaseCode.AB, PhaseCode.XYN, PhaseCode.AB, PhaseCode.AB + PhaseCode.N) + await _validate_tx_phases(PhaseCode.BC, PhaseCode.BC, PhaseCode.XYN, PhaseCode.BC, PhaseCode.BC + PhaseCode.N) + await _validate_tx_phases(PhaseCode.AC, PhaseCode.AC, PhaseCode.XYN, PhaseCode.AC, PhaseCode.AC + PhaseCode.N) + + await _validate_tx_phases(PhaseCode.AB, PhaseCode.XY, PhaseCode.XYN, PhaseCode.AB, PhaseCode.AB + PhaseCode.N) + await _validate_tx_phases(PhaseCode.BC, PhaseCode.XY, PhaseCode.XYN, PhaseCode.BC, PhaseCode.BC + PhaseCode.N) + await _validate_tx_phases(PhaseCode.AC, PhaseCode.XY, PhaseCode.XYN, PhaseCode.AC, PhaseCode.AC + PhaseCode.N) + + await _validate_tx_phases(PhaseCode.A, PhaseCode.A, PhaseCode.A + PhaseCode.N, PhaseCode.A, PhaseCode.A + PhaseCode.N) + await _validate_tx_phases(PhaseCode.B, PhaseCode.B, PhaseCode.B + PhaseCode.N, PhaseCode.B, PhaseCode.B + PhaseCode.N) + await _validate_tx_phases(PhaseCode.C, PhaseCode.C, PhaseCode.C + PhaseCode.N, PhaseCode.C, PhaseCode.C + PhaseCode.N) + + await _validate_tx_phases(PhaseCode.A, PhaseCode.A, PhaseCode.XN, PhaseCode.A, PhaseCode.A + PhaseCode.N) + await _validate_tx_phases(PhaseCode.B, PhaseCode.B, PhaseCode.XN, PhaseCode.B, PhaseCode.B + PhaseCode.N) + await _validate_tx_phases(PhaseCode.C, PhaseCode.C, PhaseCode.XN, PhaseCode.C, PhaseCode.C + PhaseCode.N) + + await _validate_tx_phases(PhaseCode.A, PhaseCode.X, PhaseCode.XN, PhaseCode.A, PhaseCode.A + PhaseCode.N) + await _validate_tx_phases(PhaseCode.B, PhaseCode.X, PhaseCode.XN, PhaseCode.B, PhaseCode.B + PhaseCode.N) + await _validate_tx_phases(PhaseCode.C, PhaseCode.X, PhaseCode.XN, PhaseCode.C, PhaseCode.C + PhaseCode.N) @pytest.mark.asyncio async def test_energises_transformer_phases_added(): diff --git a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py index 36cee1173..71f4c954c 100644 --- a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py +++ b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py @@ -121,46 +121,6 @@ def test_can_wrap_step_actions(self): 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_rewrapping_step_action_throws_attribute_error(self): - logging_wrapper = DebugLoggingWrapper('my desc', self.logger) - - action = StepAction(lambda item, context: None) - wrapped_action = logging_wrapper.wrap(action) - - assert isinstance(wrapped_action, StepAction) - - with pytest.raises(AttributeError): - logging_wrapper.wrap(wrapped_action) - - def test_rewrapping_queue_condition_throws_attribute_error(self): - logging_wrapper = DebugLoggingWrapper('my desc', self.logger) - - should_stop = bool_generator() - condition = QueueCondition(lambda nitem, nctx, item, ctx: next(should_stop)) - - wrapped_condition = logging_wrapper.wrap(condition) - - assert isinstance(wrapped_condition, QueueCondition) - - with pytest.raises(AttributeError): - logging_wrapper.wrap(wrapped_condition) - - def test_rewrapping_stop_condition_throws_attribute_error(self): - logging_wrapper = DebugLoggingWrapper('my desc', self.logger) - - condition = StopCondition(lambda item, context: True) - wrapped_condition = logging_wrapper.wrap(condition) - - assert isinstance(wrapped_condition, StopCondition) - - with pytest.raises(AttributeError): - logging_wrapper.wrap(wrapped_condition) - - # Ensure rewrapping conditions already wrapped by another logger requires explicit approval - logging_wrapper2 = DebugLoggingWrapper('my desc', self.logger) - with pytest.raises(AttributeError): - logging_wrapper2.wrap(wrapped_condition) - def test_adding_to_debug_logging_wrapper_increments_count_as_expected(self): logging_wrapper = DebugLoggingWrapper('my desc', self.logger) @@ -197,3 +157,23 @@ def test_adding_to_debug_logging_wrapper_increments_count_as_expected(self): # 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_traversal.py b/test/services/network/tracing/traversal/test_traversal.py index 9deea79e7..50481cc93 100644 --- a/test/services/network/tracing/traversal/test_traversal.py +++ b/test/services/network/tracing/traversal/test_traversal.py @@ -273,12 +273,16 @@ async def test_if_stopping_helper_accepts_step_actions(self): @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): - return f'data={current_value} : (next_item={next_item}, current_item={current_item})' + 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): @@ -293,17 +297,24 @@ def step_action(item, ctx: StepContext): assert len(data_capture) == 2 assert data_capture[1] == '1' - assert data_capture[2] == 'data=1 : (next_item=2, current_item=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): - return f'{current_value} : {next_item + current_item}' + 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): @@ -317,7 +328,10 @@ def step_action(item, ctx: StepContext): ) assert len(data_capture) == 1 - assert data_capture[3] == '1 : 3 : 5' + 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): @@ -328,7 +342,7 @@ def step_action(item, ctx: StepContext): 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}' @@ -341,7 +355,7 @@ def compute_initial_value(self, item: int): ) 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): From 431b355936fe696e1a68808b75b8aeda8ea0cb9e Mon Sep 17 00:00:00 2001 From: Anthony Charlton Date: Tue, 1 Jul 2025 11:08:13 +1000 Subject: [PATCH 27/28] Simplified phase codes with neutrals (e.g. PhaseCode.ABCN instead of PhaseCode.ABC + PhaseCode.N) and removed last remaining **kwargs. Signed-off-by: Anthony Charlton --- .../network/tracing/traversal/traversal.py | 2 +- .../network/tracing/phases/test_set_phases.py | 50 ++++++++++--------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/zepben/evolve/services/network/tracing/traversal/traversal.py b/src/zepben/evolve/services/network/tracing/traversal/traversal.py index 406f8140e..48dfee329 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/traversal.py +++ b/src/zepben/evolve/services/network/tracing/traversal/traversal.py @@ -338,7 +338,7 @@ def _(self, action: StepAction): return self @add_step_action.register(Callable) - def _(self, action: StepActionFunc, **kwargs): + def _(self, action: StepActionFunc): return self.add_step_action(StepAction(action)) @singledispatchmethod diff --git a/test/services/network/tracing/phases/test_set_phases.py b/test/services/network/tracing/phases/test_set_phases.py index 799ba1048..ac75a7c20 100644 --- a/test/services/network/tracing/phases/test_set_phases.py +++ b/test/services/network/tracing/phases/test_set_phases.py @@ -255,6 +255,7 @@ async def test_applies_unknown_phases_through_transformers(): 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) @@ -280,30 +281,31 @@ async def test_energises_transformer_phases_straight(): 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.ABC + PhaseCode.N, PhaseCode.ABC, PhaseCode.ABC + PhaseCode.N) - await _validate_tx_phases(PhaseCode.AB, PhaseCode.AB, PhaseCode.AB + PhaseCode.N, PhaseCode.AB, PhaseCode.AB + PhaseCode.N) - await _validate_tx_phases(PhaseCode.BC, PhaseCode.BC, PhaseCode.BC + PhaseCode.N, PhaseCode.BC, PhaseCode.BC + PhaseCode.N) - await _validate_tx_phases(PhaseCode.AC, PhaseCode.AC, PhaseCode.AC + PhaseCode.N, PhaseCode.AC, PhaseCode.AC + PhaseCode.N) - - await _validate_tx_phases(PhaseCode.AB, PhaseCode.AB, PhaseCode.XYN, PhaseCode.AB, PhaseCode.AB + PhaseCode.N) - await _validate_tx_phases(PhaseCode.BC, PhaseCode.BC, PhaseCode.XYN, PhaseCode.BC, PhaseCode.BC + PhaseCode.N) - await _validate_tx_phases(PhaseCode.AC, PhaseCode.AC, PhaseCode.XYN, PhaseCode.AC, PhaseCode.AC + PhaseCode.N) - - await _validate_tx_phases(PhaseCode.AB, PhaseCode.XY, PhaseCode.XYN, PhaseCode.AB, PhaseCode.AB + PhaseCode.N) - await _validate_tx_phases(PhaseCode.BC, PhaseCode.XY, PhaseCode.XYN, PhaseCode.BC, PhaseCode.BC + PhaseCode.N) - await _validate_tx_phases(PhaseCode.AC, PhaseCode.XY, PhaseCode.XYN, PhaseCode.AC, PhaseCode.AC + PhaseCode.N) - - await _validate_tx_phases(PhaseCode.A, PhaseCode.A, PhaseCode.A + PhaseCode.N, PhaseCode.A, PhaseCode.A + PhaseCode.N) - await _validate_tx_phases(PhaseCode.B, PhaseCode.B, PhaseCode.B + PhaseCode.N, PhaseCode.B, PhaseCode.B + PhaseCode.N) - await _validate_tx_phases(PhaseCode.C, PhaseCode.C, PhaseCode.C + PhaseCode.N, PhaseCode.C, PhaseCode.C + PhaseCode.N) - - await _validate_tx_phases(PhaseCode.A, PhaseCode.A, PhaseCode.XN, PhaseCode.A, PhaseCode.A + PhaseCode.N) - await _validate_tx_phases(PhaseCode.B, PhaseCode.B, PhaseCode.XN, PhaseCode.B, PhaseCode.B + PhaseCode.N) - await _validate_tx_phases(PhaseCode.C, PhaseCode.C, PhaseCode.XN, PhaseCode.C, PhaseCode.C + PhaseCode.N) - - await _validate_tx_phases(PhaseCode.A, PhaseCode.X, PhaseCode.XN, PhaseCode.A, PhaseCode.A + PhaseCode.N) - await _validate_tx_phases(PhaseCode.B, PhaseCode.X, PhaseCode.XN, PhaseCode.B, PhaseCode.B + PhaseCode.N) - await _validate_tx_phases(PhaseCode.C, PhaseCode.X, PhaseCode.XN, PhaseCode.C, PhaseCode.C + PhaseCode.N) + 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(): From ffe3278793cf241ca5194dbc22d6120dead5c380 Mon Sep 17 00:00:00 2001 From: Anthony Charlton Date: Tue, 1 Jul 2025 13:43:41 +1000 Subject: [PATCH 28/28] Formatting fixes and removed unused imports. Signed-off-by: Anthony Charlton --- changelog.md | 3 +- .../evolve/model/busbranch/bus_branch.py | 9 +- .../tracing/feeder/assign_to_feeders.py | 33 ++++---- .../tracing/feeder/assign_to_lv_feeders.py | 83 ++++++++++--------- .../network/tracing/feeder/clear_direction.py | 16 ++-- .../network/tracing/feeder/set_direction.py | 19 ++--- .../network/tracing/find_swer_equipment.py | 29 +++++-- .../actions/equipment_tree_builder.py | 4 +- .../equipment_type_step_limit_condition.py | 2 +- .../tracing/networktrace/network_trace.py | 13 ++- .../networktrace/network_trace_step.py | 1 - .../operators/network_state_operators.py | 43 ++++++---- .../network/tracing/networktrace/tracing.py | 74 +++++++++-------- .../network/tracing/phases/phase_inferrer.py | 14 ++-- .../network/tracing/phases/remove_phases.py | 21 ++--- .../network/tracing/phases/set_phases.py | 49 ++++++----- .../tracing/traversal/debug_logging.py | 3 +- .../tracing/traversal/queue_condition.py | 1 + .../network/tracing/traversal/step_context.py | 2 +- .../tracing/traversal/stop_condition.py | 2 +- .../network/tracing/traversal/traversal.py | 2 - .../evolve/testing/test_network_builder.py | 8 +- .../actions/test_equipment_tree_builder.py | 5 +- .../networktrace/test_network_trace.py | 29 ++++--- .../tracing/phases/test_phase_inferrer.py | 4 +- .../network/tracing/phases/test_set_phases.py | 36 +++++--- .../traversal/test_debug_logging_wrapper.py | 2 - .../tracing/traversal/test_traversal.py | 24 ++++-- 28 files changed, 292 insertions(+), 239 deletions(-) diff --git a/changelog.md b/changelog.md index 372d9f69a..17e1cd9a8 100644 --- a/changelog.md +++ b/changelog.md @@ -36,7 +36,8 @@ * You can now add sites to the `TestNetworkBuilder` via `addSite`. * You can now add busbar sections natively with `from_busbar_section` and `to_busbar_section` * The prefix for generated mRIDs for "other" equipment can be specified with the `default_mrid_prefix` argument in `from_other` and `to_other`. -* When processing feeder assignments, all LV feeders belonging to a dist substation site will now be considered energized when the site is energized by a feeder. +* 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` diff --git a/src/zepben/evolve/model/busbranch/bus_branch.py b/src/zepben/evolve/model/busbranch/bus_branch.py index 8bcee3915..a822220d3 100644 --- a/src/zepben/evolve/model/busbranch/bus_branch.py +++ b/src/zepben/evolve/model/busbranch/bus_branch.py @@ -33,7 +33,6 @@ "TerminalGrouping" ] - BBN = TypeVar('BBN') # Bus-Branch Network TN = TypeVar('TN') # Topological Node TB = TypeVar('TB') # Topological Branch @@ -916,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] @@ -1020,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/feeder/assign_to_feeders.py b/src/zepben/evolve/services/network/tracing/feeder/assign_to_feeders.py index c7aff9b7d..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 @@ -12,12 +12,12 @@ 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 +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: @@ -36,10 +36,12 @@ class AssignToFeeders: 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): + 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. @@ -57,7 +59,7 @@ async def run(self, class BaseFeedersInternal: - def __init__(self, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL, debug_logger: Logger=None): + 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 @@ -71,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): @@ -101,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 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 9e0e419c9..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 @@ -10,11 +10,11 @@ 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 @@ -32,15 +32,17 @@ class AssignToLvFeeders: Requires that a Feeder have a normalHeadTerminal with associated ConductingEquipment. This class is backed by a `BasicTraversal`. """ - - def __init__(self, debug_logger: Logger=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): + 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. @@ -55,14 +57,14 @@ async def run(self, ).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 - ): - + 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 @@ -73,12 +75,14 @@ async def _(self, lv_feeders_to_assign ) -class AssignToLvFeedersInternal(BaseFeedersInternal): - async def run(self, - network: NetworkService, - start_terminal: Terminal=None): +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 @@ -106,12 +110,13 @@ 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 @@ -121,11 +126,12 @@ async def run_with_feeders(self, 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 @@ -148,14 +154,15 @@ async def step_action(nts: NetworkTraceStep, context): .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 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 bafb41c76..eb57894e7 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py +++ b/src/zepben/evolve/services/network/tracing/feeder/clear_direction.py @@ -5,17 +5,16 @@ from __future__ import annotations from logging import Logger -from typing import TYPE_CHECKING, Any, TypeVar, Type +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.conditions.conditions import stop_at_open +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 @@ -26,7 +25,7 @@ class ClearDirection: """Convenience class that provides methods for clearing feeder direction on a `NetworkService`""" - def __init__(self, debug_logger: Logger=None): + 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. @@ -37,7 +36,7 @@ def __init__(self, debug_logger: Logger=None): async def run( self, terminal: Terminal, - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL ) -> list[Terminal]: """ Clears the feeder direction from a terminal and the connected equipment chain. @@ -49,7 +48,7 @@ async def run( :param network_state_operators: The `NetworkStateOperators` to be used when removing directions. :return : A set of feeder head `Terminal`s encountered when clearing directions """ - + trace = self._create_trace(network_state_operators, feeder_head_terminals := []) await trace.run(terminal, can_stop_on_start_item=False) return feeder_head_terminals @@ -59,7 +58,6 @@ def _create_trace( 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 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 2457e4198..7539eef6f 100644 --- a/src/zepben/evolve/services/network/tracing/feeder/set_direction.py +++ b/src/zepben/evolve/services/network/tracing/feeder/set_direction.py @@ -8,18 +8,18 @@ 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: @@ -33,8 +33,8 @@ class SetDirection: Convenience class that provides methods for setting feeder direction on a [NetworkService] This class is backed by a [BranchRecursiveTraversal]. """ - - def __init__(self, debug_logger: Logger=None): + + def __init__(self, debug_logger: Logger = None): self._debug_logger = debug_logger @staticmethod @@ -89,7 +89,7 @@ def _create_traversal(self, state_operators: Type[NetworkStateOperators]) -> Net network_state_operators=state_operators, action_step_type=NetworkTraceActionType.ALL_STEPS, debug_logger=self._debug_logger, - name= f'SetDirection({state_operators.description})', + 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) @@ -130,7 +130,7 @@ async def run(self, network: NetworkService, network_state_operators: Type[Netwo 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]. @@ -140,4 +140,3 @@ async def run_terminal(self, terminal: Terminal, network_state_operators: Type[N 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 ef810570b..fcac9cb23 100644 --- a/src/zepben/evolve/services/network/tracing/find_swer_equipment.py +++ b/src/zepben/evolve/services/network/tracing/find_swer_equipment.py @@ -9,14 +9,14 @@ 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.conditions.conditions import stop_at_open from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing if TYPE_CHECKING: @@ -32,10 +32,14 @@ class FindSwerEquipment: A class which can be used for finding the SWER equipment in a [NetworkService] or [Feeder]. """ - def __init__(self, debug_logger: Logger=None): + 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]: + 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` @@ -52,7 +56,11 @@ async def find(self, to_process: Union[NetworkService, Feeder], network_state_op else: raise NotImplementedError - async def find_all(self, network_service: NetworkService, network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL) -> AsyncGenerator[ConductingEquipment, None]: + 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. @@ -67,7 +75,11 @@ async def find_all(self, network_service: NetworkService, network_state_operator 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: Type[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. @@ -123,7 +135,6 @@ def condition(next_step, nctx, step, ctx): trace.reset() await trace.run(it, None) - async def _trace_lv_from( self, state_operators: Type[NetworkStateOperators], @@ -146,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 40006493c..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,7 +34,7 @@ class EquipmentTreeBuilder(StepActionWithContextValue): >>> .add_step_action(tree_builder)).run() """ - _roots: dict[ConductingEquipment, EquipmentTreeNode]={} + _roots: dict[ConductingEquipment, EquipmentTreeNode] = {} def __init__(self): super().__init__(key=str(uuid.uuid4())) @@ -68,4 +68,4 @@ def _apply(self, item: NetworkTraceStep[Any], context: StepContext): 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 d4e93e259..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.stop_condition import StopConditionWithContextValue from zepben.evolve.services.network.tracing.traversal.context_value_computer import ContextValueComputer +from zepben.evolve.services.network.tracing.traversal.stop_condition import StopConditionWithContextValue if TYPE_CHECKING: from zepben.evolve import ConductingEquipment, StepContext, NetworkTraceStep 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 0347b981c..4e560b96d 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/network_trace.py @@ -8,26 +8,25 @@ 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, CanActionItem -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') 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 de5e697dd..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 @@ -94,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): 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 cad8ca804..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,6 +20,7 @@ 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 @@ -29,11 +29,13 @@ # 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. @@ -64,12 +66,14 @@ 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. """ @@ -88,12 +92,15 @@ 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. """ diff --git a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py index c9d12063c..b0dd041b9 100644 --- a/src/zepben/evolve/services/network/tracing/networktrace/tracing.py +++ b/src/zepben/evolve/services/network/tracing/networktrace/tracing.py @@ -18,12 +18,12 @@ class Tracing: @staticmethod 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 + 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. @@ -41,22 +41,24 @@ def network_trace( 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, - name, - compute_data, - debug_logger=debug_logger) + 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: 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 + 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 @@ -78,51 +80,53 @@ def network_trace_branching( 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, - name, - None, - compute_data, - debug_logger=debug_logger) + 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(debug_logger: Logger=None): + def set_direction(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.feeder.set_direction import SetDirection return SetDirection(debug_logger=debug_logger) @staticmethod - def clear_direction(debug_logger: Logger=None): + def clear_direction(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.feeder.clear_direction import ClearDirection return ClearDirection(debug_logger=debug_logger) @staticmethod - def assign_equipment_to_feeders(debug_logger: Logger=None): + def assign_equipment_to_feeders(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.feeder.assign_to_feeders import AssignToFeeders return AssignToFeeders(debug_logger=debug_logger) @staticmethod - def assign_equipment_to_lv_feeders(debug_logger: Logger=None): + 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(debug_logger=debug_logger) @staticmethod - def set_phases(debug_logger: Logger=None): + def set_phases(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.phases.set_phases import SetPhases return SetPhases(debug_logger=debug_logger) @staticmethod - def remove_phases(debug_logger: Logger=None): + def remove_phases(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.phases.remove_phases import RemovePhases return RemovePhases(debug_logger=debug_logger) @staticmethod - def phase_inferrer(debug_logger: Logger=None): + def phase_inferrer(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.phases.phase_inferrer import PhaseInferrer return PhaseInferrer(debug_logger=debug_logger) @staticmethod - def find_swer_equipment(debug_logger: Logger=None): + def find_swer_equipment(debug_logger: Logger = None): from zepben.evolve.services.network.tracing.find_swer_equipment import 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 375044dd7..0ffc0c19a 100644 --- a/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py +++ b/src/zepben/evolve/services/network/tracing/phases/phase_inferrer.py @@ -9,8 +9,8 @@ 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 @@ -23,7 +23,7 @@ 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): + def __init__(self, debug_logger: Logger = None): self._debug_logger = debug_logger @dataclass @@ -41,7 +41,7 @@ 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`. @@ -56,7 +56,7 @@ async def run(self, network: NetworkService, network_state_operators: Type[Netwo return [self.InferredPhase(k, v) for k, v in tracking.items()] class PhaseInferrerInternal: - def __init__(self, state_operators: Type[NetworkStateOperators], debug_logger: Logger=None): + def __init__(self, state_operators: Type[NetworkStateOperators], debug_logger: Logger = None): self.state_operators = state_operators self._debug_logger = debug_logger @@ -66,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 @@ -112,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 [ 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 ab975c485..19cc9591f 100644 --- a/src/zepben/evolve/services/network/tracing/phases/remove_phases.py +++ b/src/zepben/evolve/services/network/tracing/phases/remove_phases.py @@ -11,12 +11,12 @@ 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.networktrace.conditions.conditions import stop_at_open from zepben.evolve.services.network.tracing.traversal.weighted_priority_queue import WeightedPriorityQueue if TYPE_CHECKING: @@ -36,14 +36,15 @@ class RemovePhases(object): This class is backed by a `BranchRecursiveTraversal`. """ - def __init__(self, debug_logger: Logger=None): + 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): + 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` @@ -67,7 +68,7 @@ async def run( 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) -> None: + async def _run_with_network(network_service: NetworkService, network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL) -> None: """ Remove all traced phases from the specified network. @@ -78,7 +79,7 @@ async def _run_with_network(network_service: NetworkService, network_state_opera 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 @@ -92,7 +93,7 @@ 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): + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL): """ Allows the removal of traced phases from a terminal and the connected equipment chain @@ -119,7 +120,7 @@ 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( @@ -130,8 +131,8 @@ def queue_condition(next_step: NetworkTraceStep, next_ctx: StepContext=None, ste queue=WeightedPriorityQueue.process_queue(lambda it: len(it.data.phases_to_ebb)), compute_data=compute_data ).add_condition(stop_at_open()) \ - .add_step_action(step_action) \ - .add_queue_condition(queue_condition) + .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 965682369..5ecab8ce4 100644 --- a/src/zepben/evolve/services/network/tracing/phases/set_phases.py +++ b/src/zepben/evolve/services/network/tracing/phases/set_phases.py @@ -39,7 +39,7 @@ class SetPhases: This class is backed by a `NetworkTrace`. """ - def __init__(self, debug_logger: Logger=None): + def __init__(self, debug_logger: Logger = None): self._debug_logger = debug_logger class PhasesToFlow: @@ -54,8 +54,9 @@ def __str__(self): async def run( self, target: Union[NetworkService, Terminal], - phases: Union[PhaseCode, Iterable[SinglePhaseKind]]=None, - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + phases: Union[PhaseCode, Iterable[SinglePhaseKind]] = None, + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL + ): """ :param target: @@ -69,7 +70,8 @@ async def run( async def _( self, network: NetworkService, - network_state_operators: Type[NetworkStateOperators]=NetworkStateOperators.NORMAL): + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL + ): """ 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. @@ -90,9 +92,10 @@ def _terminals_from_network(): 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): + phases: Union[PhaseCode, List[SinglePhaseKind], Set[SinglePhaseKind]] = None, + network_state_operators: Type[NetworkStateOperators] = NetworkStateOperators.NORMAL, + seed_terminal: Terminal = None + ): """ 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` @@ -133,8 +136,9 @@ 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 nominal phases from the `from_terminal` to the `to_terminal`. @@ -249,14 +253,15 @@ def inner(step, _, next_path): self._nominal_phase_path_to_phases(step.data.nominal_phase_paths) ) ) + return ComputeData(inner) @staticmethod def _apply_phases( phases: List[SinglePhaseKind], terminal: Terminal, - state_operators: Type[NetworkStateOperators]): - + 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 @@ -266,7 +271,7 @@ def _get_nominal_phase_paths( state_operators: Type[NetworkStateOperators], from_terminal: Terminal, to_terminal: Terminal, - phases: Sequence[SinglePhaseKind]=None + phases: Sequence[SinglePhaseKind] = None ) -> List[NominalPhasePath]: if phases is None: @@ -332,8 +337,8 @@ def _flow_transformer_phases( state_operators: Type[NetworkStateOperators], from_terminal: Terminal, to_terminal: Terminal, - nominal_phase_paths: List[NominalPhasePath]=None, - allow_suspect_flow: bool=False + nominal_phase_paths: List[NominalPhasePath] = None, + allow_suspect_flow: bool = False ) -> bool: paths = nominal_phase_paths or self._get_nominal_phase_paths(state_operators, from_terminal, to_terminal) @@ -394,8 +399,8 @@ def _try_set_phase( to_terminal: Terminal, to_phases: PhaseStatus, to_: SinglePhaseKind, - on_success: Callable[[], None]): - + on_success: Callable[[], None] + ): try: if phase != SinglePhaseKind.NONE and to_phases.__setitem__(to_, phase): if self._debug_logger: @@ -412,8 +417,8 @@ def _try_add_phase( to_phases: PhaseStatus, to_: SinglePhaseKind, allow_suspect_flow: bool, - on_success: Callable[[], None]): - + 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. @@ -430,8 +435,8 @@ def _throw_cross_phase_exception( from_: SinglePhaseKind, to_terminal: Terminal, to_phases: PhaseStatus, - to_: SinglePhaseKind): - + to_: SinglePhaseKind + ): phase_desc = f'{from_.name}' if from_ == to_ else f'path {from_.name} to {to_.name}' def get_ce_details(terminal: Terminal): @@ -450,17 +455,19 @@ def get_ce_details(terminal: Terminal): "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: +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. diff --git a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py index 2cf45aab9..185c2e045 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py +++ b/src/zepben/evolve/services/network/tracing/traversal/debug_logging.py @@ -8,7 +8,7 @@ import functools from logging import Logger from types import FunctionType -from typing import TypeVar, Union, Optional, Type, TypedDict, List, Tuple, Dict +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 @@ -16,7 +16,6 @@ T = TypeVar('T') - Wrappable = Union[StepAction[T], QueueCondition[T], StopCondition[T]] DebugLoggingDataParam = List[Tuple[Union[Tuple[str, str], str], str]] 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 9af6c166c..9525a1d6e 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/queue_condition.py +++ b/src/zepben/evolve/services/network/tracing/traversal/queue_condition.py @@ -56,6 +56,7 @@ def should_queue_start_item(item: T) -> bool: from zepben.evolve.services.network.tracing.traversal.context_value_computer import ContextValueComputer + 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. 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 0e6693f4d..76e7f0502 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/step_context.py +++ b/src/zepben/evolve/services/network/tracing/traversal/step_context.py @@ -24,7 +24,7 @@ class StepContext(Generic[T]): :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 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 db6e25bc7..532e0f874 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/stop_condition.py +++ b/src/zepben/evolve/services/network/tracing/traversal/stop_condition.py @@ -24,7 +24,7 @@ class StopCondition(Generic[T], TraversalCondition[T]): `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 diff --git a/src/zepben/evolve/services/network/tracing/traversal/traversal.py b/src/zepben/evolve/services/network/tracing/traversal/traversal.py index 48dfee329..43b5c968a 100644 --- a/src/zepben/evolve/services/network/tracing/traversal/traversal.py +++ b/src/zepben/evolve/services/network/tracing/traversal/traversal.py @@ -13,8 +13,6 @@ from logging import Logger from typing import List, TypeVar, Generic, Optional, Dict, Union -from typing_extensions import Required - 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 diff --git a/src/zepben/evolve/testing/test_network_builder.py b/src/zepben/evolve/testing/test_network_builder.py index a3af3dc30..508c877b0 100644 --- a/src/zepben/evolve/testing/test_network_builder.py +++ b/src/zepben/evolve/testing/test_network_builder.py @@ -3,14 +3,12 @@ # 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 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) +from zepben.evolve.services.network.tracing.networktrace.operators.network_state_operators import NetworkStateOperators +from zepben.evolve.services.network.tracing.networktrace.tracing import Tracing SubclassesConductingEquipment = TypeVar('SubclassesConductingEquipment', bound=ConductingEquipment) @@ -472,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) - self._add_terminal(clamp, 1 , nominal_phases) + self._add_terminal(clamp, 1, nominal_phases) acls.add_clamp(clamp) action(clamp) 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 060dfd921..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 diff --git a/test/services/network/tracing/networktrace/test_network_trace.py b/test/services/network/tracing/networktrace/test_network_trace.py index 70178433b..895e4d13a 100644 --- a/test/services/network/tracing/networktrace/test_network_trace.py +++ b/test/services/network/tracing/networktrace/test_network_trace.py @@ -11,7 +11,8 @@ 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, 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__ @@ -38,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): @@ -128,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): @@ -152,7 +153,7 @@ 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): @@ -187,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)) @@ -282,5 +283,3 @@ async def validate(start: Tuple[str, str], action_step_type: NetworkTraceActionT # 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 040063326..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,7 +447,6 @@ 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__) diff --git a/test/services/network/tracing/phases/test_set_phases.py b/test/services/network/tracing/phases/test_set_phases.py index ac75a7c20..06d6b327e 100644 --- a/test/services/network/tracing/phases/test_set_phases.py +++ b/test/services/network/tracing/phases/test_set_phases.py @@ -8,11 +8,8 @@ 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 @pytest.mark.asyncio @@ -221,21 +218,23 @@ async def test_detects_cross_phasing_connected(): 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() + .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(): # @@ -251,6 +250,7 @@ async def test_applies_unknown_phases_through_transformers(): 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. @@ -307,6 +307,7 @@ async def test_energises_transformer_phases_straight(): 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(): # @@ -409,6 +410,7 @@ async def test_energises_transformer_phases_dropped(): # `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(): # @@ -424,6 +426,7 @@ async def test_applies_phases_to_unknown_hv(): 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(): # @@ -439,6 +442,7 @@ async def test_applies_phases_to_unknown_lv(): 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(): # @@ -454,6 +458,7 @@ async def test_applies_phases_on_to_swerv(): 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(): # @@ -469,6 +474,7 @@ async def test_uses_transformer_paths(): 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(): # @@ -495,6 +501,7 @@ async def test_does_not_remove_phase_when_applying_subset_out_of_loop(): 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(): """ @@ -540,7 +547,7 @@ async def test_can_back_trace_through_xn_xy_transformer_spur(): # 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) @@ -550,6 +557,7 @@ def action(ce: ConductingEquipment): return action + @pytest.mark.asyncio async def test_can_set_phases_from_an_unknown_nominal_phase(): """ @@ -571,6 +579,7 @@ 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(): # @@ -616,7 +625,14 @@ async def test_energises_around_dropped_phase_dual_transformer_loop(): 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]]): + +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 diff --git a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py index 71f4c954c..bdadf871b 100644 --- a/test/services/network/tracing/traversal/test_debug_logging_wrapper.py +++ b/test/services/network/tracing/traversal/test_debug_logging_wrapper.py @@ -6,8 +6,6 @@ import queue from contextlib import contextmanager -import pytest - from zepben.evolve import StepContext, StopCondition, QueueCondition, StepAction from zepben.evolve.services.network.tracing.traversal.debug_logging import DebugLoggingWrapper diff --git a/test/services/network/tracing/traversal/test_traversal.py b/test/services/network/tracing/traversal/test_traversal.py index 50481cc93..dd3d46684 100644 --- a/test/services/network/tracing/traversal/test_traversal.py +++ b/test/services/network/tracing/traversal/test_traversal.py @@ -15,7 +15,6 @@ class TraversalTest(Traversal[T, D]): - name = 'TestTraversal' def __init__( @@ -51,7 +50,6 @@ def _create_traversal( 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) @@ -84,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: @@ -95,7 +94,8 @@ 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) @@ -108,7 +108,8 @@ 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) @@ -120,7 +121,8 @@ 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) @@ -183,7 +185,8 @@ 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) @@ -277,6 +280,7 @@ async def test_if_not_stopping_helper_accepts_step_action_with_context_value_and 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})' @@ -309,6 +313,7 @@ async def test_if_stopping_helper_accepts_step_action_with_context_value_and_con 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})' @@ -347,7 +352,8 @@ def compute_next_value(self, next_item: int, current_item: int, current_value): 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) @@ -434,9 +440,9 @@ async def test_supports_branching_traversals(self): def step_action(item, ctx): steps[item] = ctx - trace =( + trace = ( _create_branching_traversal() - .add_queue_condition(lambda item, ctx, x, y: (ctx.branch_depth <= 1) and (item != 0)) + .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)