From e11390d12ee5ccdff99cc37e3b84aa8a5f33bd7f Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Mon, 2 Jun 2025 18:10:54 +0100 Subject: [PATCH 1/4] adding transforms --- .github/workflows/python-test.yml | 7 +- Dockerfile | 27 +- ngraph/__init__.py | 1 + ngraph/explorer.py | 343 ++++++++++---------- ngraph/transform/__init__.py | 20 ++ ngraph/transform/base.py | 78 +++++ ngraph/transform/distribute_external.py | 120 +++++++ ngraph/transform/enable_nodes.py | 46 +++ notebooks/bb_fabric.ipynb | 186 +++++++++++ notebooks/small_demo.ipynb | 12 +- requirements.txt | 3 - tests/transform/__init__.py | 0 tests/transform/test_base.py | 33 ++ tests/transform/test_distribute_external.py | 99 ++++++ tests/transform/test_enable_nodes.py | 62 ++++ 15 files changed, 833 insertions(+), 204 deletions(-) create mode 100644 ngraph/transform/__init__.py create mode 100644 ngraph/transform/base.py create mode 100644 ngraph/transform/distribute_external.py create mode 100644 ngraph/transform/enable_nodes.py create mode 100644 notebooks/bb_fabric.ipynb delete mode 100644 requirements.txt create mode 100644 tests/transform/__init__.py create mode 100644 tests/transform/test_base.py create mode 100644 tests/transform/test_distribute_external.py create mode 100644 tests/transform/test_enable_nodes.py diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index c644fa9..a8c5da5 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -19,12 +19,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install pytest pytest-cov pytest-benchmark pytest-mock - python -m pip install networkx - if [ -f requirements.txt ]; - then - pip install -r requirements.txt; - fi + python -m pip install . pytest pytest-cov pytest-benchmark - name: Test with pytest and check test coverage run: | pytest --cov=ngraph --cov-fail-under=85 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 74d972b..3c2e0d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,22 @@ # Stage 1: Base image with system dependencies -FROM python:3.13 AS base +FROM python:3.13-slim AS base # Prevent interactive config during installation ENV DEBIAN_FRONTEND=noninteractive # Install system dependencies and cleanup RUN apt-get update && \ + apt-get upgrade -y && \ apt-get install -y \ - build-essential \ - cmake \ - curl \ - wget \ - unzip \ - git \ - libgeos-dev \ - libproj-dev \ - libgdal-dev \ + build-essential \ + cmake \ + curl \ + wget \ + unzip \ + git \ + libgeos-dev \ + libproj-dev \ + libgdal-dev \ && apt-get clean && \ rm -rf /var/lib/apt/lists/* @@ -25,10 +26,6 @@ FROM base AS jupyterlab # Upgrade pip and setuptools RUN pip install --no-cache-dir --upgrade pip setuptools wheel -# Copy requirements first to leverage cache -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - # Install Python packages RUN pip install --no-cache-dir \ numpy \ @@ -71,4 +68,4 @@ VOLUME /root/env ENTRYPOINT ["/tini", "-g", "--"] # Default command to run when the container starts -CMD ["/bin/bash"] \ No newline at end of file +CMD ["/bin/bash"] diff --git a/ngraph/__init__.py b/ngraph/__init__.py index e69de29..c8163b1 100644 --- a/ngraph/__init__.py +++ b/ngraph/__init__.py @@ -0,0 +1 @@ +import ngraph.transform diff --git a/ngraph/explorer.py b/ngraph/explorer.py index 3e4319f..38140f6 100644 --- a/ngraph/explorer.py +++ b/ngraph/explorer.py @@ -36,10 +36,9 @@ class TreeStats: internal_link_capacity (float): Sum of capacities for those internal links. external_link_count (int): Number of external links from this subtree to another. external_link_capacity (float): Sum of capacities for those external links. - external_link_details (Dict[str, ExternalLinkBreakdown]): Breakdown of external - links by the other subtree's path. - total_cost (float): Cumulative cost from node 'hw_component' plus link 'hw_component'. - total_power (float): Cumulative power from node 'hw_component' plus link 'hw_component'. + external_link_details (Dict[str, ExternalLinkBreakdown]): Breakdown by other subtree path. + total_cost (float): Cumulative cost (nodes + links). + total_power (float): Cumulative power (nodes + links). """ node_count: int = 0 @@ -64,39 +63,37 @@ class TreeNode: Represents a node in the hierarchical tree. Attributes: - name (str): Name/label of this node (e.g., "dc1", "plane1", etc.). + name (str): Name/label of this node. parent (Optional[TreeNode]): Pointer to the parent tree node. children (Dict[str, TreeNode]): Mapping of child name -> child TreeNode. - subtree_nodes (Set[str]): The set of all node names in this subtree. - stats (TreeStats): Computed statistics for this subtree. - raw_nodes (List[Node]): Direct Node objects at this hierarchical level. + subtree_nodes (Set[str]): Node names in the subtree (all nodes, ignoring disabled). + active_subtree_nodes (Set[str]): Node names in the subtree (only enabled). + stats (TreeStats): Aggregated stats for "all" view. + active_stats (TreeStats): Aggregated stats for "active" (only enabled) view. + raw_nodes (List[Node]): Direct Node objects at this hierarchy level. """ name: str parent: Optional[TreeNode] = None children: Dict[str, TreeNode] = field(default_factory=dict) + + # "All" includes disabled nodes; "Active" excludes them. subtree_nodes: Set[str] = field(default_factory=set) + active_subtree_nodes: Set[str] = field(default_factory=set) + stats: TreeStats = field(default_factory=TreeStats) + active_stats: TreeStats = field(default_factory=TreeStats) + raw_nodes: List[Node] = field(default_factory=list) def __hash__(self) -> int: - """ - Make the node hashable based on object identity. - This preserves uniqueness in sets/dicts without - forcing equality by fields. - """ + # Keep identity-based hashing so each node is unique in sets/dicts. return id(self) def add_child(self, child_name: str) -> TreeNode: """ Ensure a child node named 'child_name' exists and return it. - - Args: - child_name (str): The name of the child node to add/find. - - Returns: - TreeNode: The new or existing child TreeNode. """ if child_name not in self.children: child_node = TreeNode(name=child_name, parent=self) @@ -106,18 +103,14 @@ def add_child(self, child_name: str) -> TreeNode: def is_leaf(self) -> bool: """ Return True if this node has no children. - - Returns: - bool: True if there are no children, False otherwise. """ return len(self.children) == 0 class NetworkExplorer: """ - Provides hierarchical exploration of a Network, computing internal/external - link counts, node counts, and cost/power usage. Also records external link - breakdowns by subtree path, with optional roll-up of leaf nodes in display. + Provides hierarchical exploration of a Network, computing statistics in two modes: + 'all' (ignores disabled) and 'active' (only enabled). """ def __init__( @@ -125,16 +118,6 @@ def __init__( network: Network, components_library: Optional[ComponentsLibrary] = None, ) -> None: - """ - Initialize a NetworkExplorer. Generally, use 'explore_network' to build - and populate stats automatically. - - Args: - network (Network): The network to explore. - components_library (Optional[ComponentsLibrary]): Library of - hardware/optic components to calculate cost/power. If None, - an empty library is used and cost/power will be 0. - """ self.network = network self.components_library = components_library or ComponentsLibrary() @@ -144,7 +127,7 @@ def __init__( self._node_map: Dict[str, TreeNode] = {} # node_name -> deepest TreeNode self._path_map: Dict[str, TreeNode] = {} # path -> TreeNode - # Cache for storing each node's ancestor set: + # Cache for ancestor sets: self._ancestors_cache: Dict[TreeNode, Set[TreeNode]] = {} @classmethod @@ -154,32 +137,24 @@ def explore_network( components_library: Optional[ComponentsLibrary] = None, ) -> NetworkExplorer: """ - Creates a NetworkExplorer, builds a hierarchy tree, and computes stats. - - NOTE: If you do not pass a non-empty components_library, any hardware - references for cost/power data will not be found. - - Args: - network (Network): The network to explore. - components_library (Optional[ComponentsLibrary]): Components library - to use for cost/power lookups. - - Returns: - NetworkExplorer: A fully populated explorer instance with stats. + Build a NetworkExplorer, constructing a tree plus 'all' and 'active' stats. """ instance = cls(network, components_library) - # 1) Build the hierarchical structure + # 1) Build hierarchy instance.root_node = instance._build_hierarchy_tree() - # 2) Compute subtree sets (subtree_nodes) - instance._compute_subtree_sets(instance.root_node) + # 2) Compute subtree sets for "all" (ignoring disabled state) + instance._compute_subtree_sets_all(instance.root_node) - # 3) Build node and path maps + # 3) Compute subtree sets for "active" (excluding disabled) + instance._compute_subtree_sets_active(instance.root_node) + + # 4) Build node & path maps instance._build_node_map(instance.root_node) instance._build_path_map(instance.root_node) - # 4) Aggregate statistics (node counts, link stats, cost, power) + # 5) Aggregate statistics (both 'all' and 'active') instance._compute_statistics() return instance @@ -188,9 +163,6 @@ def _build_hierarchy_tree(self) -> TreeNode: """ Build a multi-level tree by splitting node names on '/'. Example: "dc1/plane1/ssw/ssw-1" => root/dc1/plane1/ssw/ssw-1 - - Returns: - TreeNode: The root of the newly constructed tree. """ root = TreeNode(name="root") for nd in self.network.nodes.values(): @@ -201,46 +173,48 @@ def _build_hierarchy_tree(self) -> TreeNode: current.raw_nodes.append(nd) return root - def _compute_subtree_sets(self, node: TreeNode) -> Set[str]: + def _compute_subtree_sets_all(self, node: TreeNode) -> Set[str]: """ - Recursively compute the set of node names in each subtree. - - Args: - node (TreeNode): The current tree node. - - Returns: - Set[str]: A set of node names belonging to the subtree under 'node'. + Recursively collect all node names (regardless of disabled) into subtree_nodes. """ collected = set() for child in node.children.values(): - collected |= self._compute_subtree_sets(child) + collected |= self._compute_subtree_sets_all(child) for nd in node.raw_nodes: collected.add(nd.name) node.subtree_nodes = collected return collected - def _build_node_map(self, node: TreeNode) -> None: + def _compute_subtree_sets_active(self, node: TreeNode) -> Set[str]: """ - Post-order traversal to populate _node_map. - - Each node_name in 'node.subtree_nodes' maps to 'node' if not already - assigned. The "deepest" node (lowest in the hierarchy) takes precedence. + Recursively collect enabled node names into active_subtree_nodes. + A node is considered enabled if nd.attrs.get("disabled") is not truthy. + """ + collected = set() + for child in node.children.values(): + collected |= self._compute_subtree_sets_active(child) + for nd in node.raw_nodes: + if not nd.attrs.get("disabled"): + collected.add(nd.name) + node.active_subtree_nodes = collected + return collected - Args: - node (TreeNode): The current tree node. + def _build_node_map(self, node: TreeNode) -> None: """ + Assign each node's name to the *deepest* TreeNode that actually holds it. + We do a parent-first approach so children override if needed. + """ + # Map the raw_nodes at this level + for nd in node.raw_nodes: + self._node_map[nd.name] = node + + # Then recurse, letting children override deeper nodes for child in node.children.values(): self._build_node_map(child) - for node_name in node.subtree_nodes: - if node_name not in self._node_map: - self._node_map[node_name] = node def _build_path_map(self, node: TreeNode) -> None: """ - Build a path->TreeNode map for easy lookups. Skips "root" in paths. - - Args: - node (TreeNode): The current tree node. + Build a path->TreeNode map for easy lookups. Skips "root" in path strings. """ path_str = self._compute_full_path(node) self._path_map[path_str] = node @@ -250,12 +224,6 @@ def _build_path_map(self, node: TreeNode) -> None: def _compute_full_path(self, node: TreeNode) -> str: """ Return a '/'-joined path, omitting "root". - - Args: - node (TreeNode): The tree node to compute a path for. - - Returns: - str: E.g., "dc1/plane1/ssw". """ parts = [] current = node @@ -264,28 +232,9 @@ def _compute_full_path(self, node: TreeNode) -> str: current = current.parent return "/".join(reversed(parts)) - def _roll_up_if_leaf(self, path: str) -> str: - """ - If 'path' corresponds to a leaf node, climb up until a non-leaf or root - is found. Return the resulting path. - - Args: - path (str): A '/'-joined path. - - Returns: - str: Possibly re-mapped path if a leaf was rolled up. - """ - node = self._path_map.get(path) - if not node: - return path - while node.parent and node.parent.name != "root" and node.is_leaf(): - node = node.parent - return self._compute_full_path(node) - def _get_ancestors(self, node: TreeNode) -> Set[TreeNode]: """ - Return a cached set of this node's ancestors (including itself), - up to the root. + Return a cached set of this node's ancestors (including itself). """ if node in self._ancestors_cache: return self._ancestors_cache[node] @@ -300,106 +249,101 @@ def _get_ancestors(self, node: TreeNode) -> Set[TreeNode]: def _compute_statistics(self) -> None: """ - Computes all subtree statistics in a more efficient manner: - - - node_count is set from each node's 'subtree_nodes' (already stored). - - For each network node, cost/power is added to all ancestors in the - hierarchy. - - For each link, we figure out which subtrees see it as internal or - external, and update stats accordingly. + Populates two stats sets for each TreeNode: + - node.stats (all, ignoring disabled) + - node.active_stats (only enabled nodes/links) """ - # 1) node_count: use subtree sets - # (each node gets the size of subtree_nodes) - # stats are zeroed initially in the constructor. - def set_node_counts(node: TreeNode) -> None: - node.stats.node_count = len(node.subtree_nodes) - for child in node.children.values(): - set_node_counts(child) + # First, zero them out + def reset_stats(n: TreeNode): + n.stats = TreeStats() + n.active_stats = TreeStats() + for c in n.children.values(): + reset_stats(c) + + if self.root_node: + reset_stats(self.root_node) + + # 1) Node counts from subtree sets + def set_node_counts(n: TreeNode): + n.stats.node_count = len(n.subtree_nodes) + n.active_stats.node_count = len(n.active_subtree_nodes) + for c in n.children.values(): + set_node_counts(c) - set_node_counts(self.root_node) + if self.root_node: + set_node_counts(self.root_node) - # 2) Accumulate node cost/power into all ancestor stats + # 2) Accumulate node cost/power for nd in self.network.nodes.values(): - hw_component = nd.attrs.get("hw_component") + hw_comp_name = nd.attrs.get("hw_component") comp = None - if hw_component: - comp = self.components_library.get(hw_component) + if hw_comp_name: + comp = self.components_library.get(hw_comp_name) if comp is None: logger.warning( "Node '%s' references unknown hw_component '%s'.", nd.name, - hw_component, + hw_comp_name, ) - - # Walk up from the deepest node - node_for_name = self._node_map[nd.name] - ancestors = self._get_ancestors(node_for_name) - if comp: - cval = comp.total_cost() - pval = comp.total_power() - for an in ancestors: - an.stats.total_cost += cval - an.stats.total_power += pval - - # 3) Single pass to accumulate link stats - # For each link, determine for which subtrees it's internal vs external, - # and update stats accordingly. Also add link hw cost/power if applicable. + cost_val = comp.total_cost() if comp else 0.0 + power_val = comp.total_power() if comp else 0.0 + + tree_node = self._node_map[nd.name] + # "All" includes disabled + for an in self._get_ancestors(tree_node): + an.stats.total_cost += cost_val + an.stats.total_power += power_val + + # "Active" excludes disabled + if not nd.attrs.get("disabled"): + for an in self._get_ancestors(tree_node): + an.active_stats.total_cost += cost_val + an.active_stats.total_power += power_val + + # 3) Accumulate link stats (internal/external + cost/power) for link in self.network.links.values(): src = link.source dst = link.target - # Check link's hw_component - hw_comp = link.attrs.get("hw_component") + link_comp_name = link.attrs.get("hw_component") link_comp = None - if hw_comp: - link_comp = self.components_library.get(hw_comp) + if link_comp_name: + link_comp = self.components_library.get(link_comp_name) if link_comp is None: logger.warning( "Link '%s->%s' references unknown hw_component '%s'.", src, dst, - hw_comp, + link_comp_name, ) + link_cost = link_comp.total_cost() if link_comp else 0.0 + link_power = link_comp.total_power() if link_comp else 0.0 + cap = link.capacity src_node = self._node_map[src] dst_node = self._node_map[dst] A_src = self._get_ancestors(src_node) A_dst = self._get_ancestors(dst_node) - # Intersection => internal - # XOR => external - inter = A_src & A_dst - xor = A_src ^ A_dst - - # Capacity - cap = link.capacity + inter_anc = A_src & A_dst # sees link as "internal" + xor_anc = A_src ^ A_dst # sees link as "external" - # For cost/power from link, we add to any node - # that sees it either internal or external. - link_cost = link_comp.total_cost() if link_comp else 0.0 - link_power = link_comp.total_power() if link_comp else 0.0 - - # Internal link updates - for an in inter: + # ----- "ALL" stats ----- + for an in inter_anc: an.stats.internal_link_count += 1 an.stats.internal_link_capacity += cap an.stats.total_cost += link_cost an.stats.total_power += link_power - - # External link updates - for an in xor: + for an in xor_anc: an.stats.external_link_count += 1 an.stats.external_link_capacity += cap an.stats.total_cost += link_cost an.stats.total_power += link_power - # Update external_link_details if an in A_src: - # 'an' sees the other side as 'dst' other_path = self._compute_full_path(dst_node) else: - # 'an' sees the other side as 'src' other_path = self._compute_full_path(src_node) bd = an.stats.external_link_details.setdefault( other_path, ExternalLinkBreakdown() @@ -407,6 +351,36 @@ def set_node_counts(node: TreeNode) -> None: bd.link_count += 1 bd.link_capacity += cap + # ----- "ACTIVE" stats ----- + # If link or either endpoint is disabled, skip + if link.attrs.get("disabled"): + continue + if self.network.nodes[src].attrs.get("disabled"): + continue + if self.network.nodes[dst].attrs.get("disabled"): + continue + + for an in inter_anc: + an.active_stats.internal_link_count += 1 + an.active_stats.internal_link_capacity += cap + an.active_stats.total_cost += link_cost + an.active_stats.total_power += link_power + for an in xor_anc: + an.active_stats.external_link_count += 1 + an.active_stats.external_link_capacity += cap + an.active_stats.total_cost += link_cost + an.active_stats.total_power += link_power + + if an in A_src: + other_path = self._compute_full_path(dst_node) + else: + other_path = self._compute_full_path(src_node) + bd = an.active_stats.external_link_details.setdefault( + other_path, ExternalLinkBreakdown() + ) + bd.link_count += 1 + bd.link_capacity += cap + def print_tree( self, node: Optional[TreeNode] = None, @@ -414,18 +388,19 @@ def print_tree( max_depth: Optional[int] = None, skip_leaves: bool = False, detailed: bool = False, + include_disabled: bool = True, ) -> None: """ - Print the hierarchy from the given node (default: root). - If detailed=True, show link capacities and external link breakdown. - If skip_leaves=True, leaf nodes are omitted from printing (rolled up). + Print the hierarchy from 'node' down (default: root). Args: - node (Optional[TreeNode]): The node to start printing from; defaults to root. - indent (int): Indentation level for the output. - max_depth (Optional[int]): If set, stop printing deeper levels when exceeded. - skip_leaves (bool): If True, leaf nodes are not individually printed. - detailed (bool): If True, print more detailed link/capacity breakdowns. + node (TreeNode): subtree to print, or root if None + indent (int): indentation level + max_depth (int): if set, limit display depth + skip_leaves (bool): if True, skip leaf subtrees + detailed (bool): if True, print link capacity breakdowns + include_disabled (bool): If False, show stats only for enabled nodes/links. + Subtrees with zero active nodes are omitted. """ if node is None: node = self.root_node @@ -436,10 +411,17 @@ def print_tree( if max_depth is not None and indent > max_depth: return + # Pick which stats to display + stats = node.stats if include_disabled else node.active_stats + + # If 'active' mode and this node has 0 nodes, omit it (unless it's the root) + if not include_disabled and stats.node_count == 0 and node.parent is not None: + return + + # Possibly skip leaves if skip_leaves and node.is_leaf() and node.parent is not None: return - stats = node.stats total_links = stats.internal_link_count + stats.external_link_count line = ( f"{' ' * indent}- {node.name or 'root'} | " @@ -460,6 +442,7 @@ def print_tree( for other_path, info in stats.external_link_details.items(): rolled_path = other_path if skip_leaves: + # If that path is a leaf, roll up rolled_path = self._roll_up_if_leaf(rolled_path) accum = rolled_map.setdefault(rolled_path, ExternalLinkBreakdown()) accum.link_count += info.link_count @@ -474,7 +457,7 @@ def print_tree( f"{ext_info.link_count} links, cap={ext_info.link_capacity}" ) - # Recurse children + # Recurse on children for child in node.children.values(): self.print_tree( node=child, @@ -482,4 +465,16 @@ def print_tree( max_depth=max_depth, skip_leaves=skip_leaves, detailed=detailed, + include_disabled=include_disabled, ) + + def _roll_up_if_leaf(self, path: str) -> str: + """ + If 'path' is a leaf node's path, climb up until a non-leaf or root is found. + """ + node = self._path_map.get(path) + if not node: + return path + while node.parent and node.parent.name != "root" and node.is_leaf(): + node = node.parent + return self._compute_full_path(node) diff --git a/ngraph/transform/__init__.py b/ngraph/transform/__init__.py new file mode 100644 index 0000000..0251f26 --- /dev/null +++ b/ngraph/transform/__init__.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from ngraph.transform.base import ( + NetworkTransform, + TRANSFORM_REGISTRY, + register_transform, +) + +from ngraph.transform.enable_nodes import EnableNodesTransform +from ngraph.transform.distribute_external import ( + DistributeExternalConnectivity, +) + +__all__ = [ + "NetworkTransform", + "register_transform", + "TRANSFORM_REGISTRY", + "EnableNodesTransform", + "DistributeExternalConnectivity", +] diff --git a/ngraph/transform/base.py b/ngraph/transform/base.py new file mode 100644 index 0000000..dc2ff7b --- /dev/null +++ b/ngraph/transform/base.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import abc +from typing import Any, Dict, Type, Self + +from ngraph.scenario import Scenario +from ngraph.workflow.base import WorkflowStep, register_workflow_step + +TRANSFORM_REGISTRY: Dict[str, Type["NetworkTransform"]] = {} + + +def register_transform(name: str) -> Any: + """ + Class decorator that registers a concrete :class:`NetworkTransform` and + auto-wraps it as a :class:`WorkflowStep`. + + The same *name* is used for both the transform factory and the workflow + ``step_type`` in YAML. + + Raises: + ValueError: If *name* is already registered. + """ + + def decorator(cls: Type["NetworkTransform"]) -> Type["NetworkTransform"]: + if name in TRANSFORM_REGISTRY: + raise ValueError(f"Transform '{name}' already registered.") + TRANSFORM_REGISTRY[name] = cls + + @register_workflow_step(name) + class _TransformStep(WorkflowStep): + """Auto-generated wrapper that executes *cls.apply*.""" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(name=name) + self._transform = cls(**kwargs) + + def run(self, scenario: Scenario) -> None: # noqa: D401 + self._transform.apply(scenario) + + return cls + + return decorator + + +class NetworkTransform(abc.ABC): + """ + Stateless mutator applied to a :class:`ngraph.scenario.Scenario`. + + Subclasses must override :meth:`apply`. + """ + + label: str = "" + + @abc.abstractmethod + def apply(self, scenario: Scenario) -> None: + """Modify *scenario.network* in-place.""" + ... + + @classmethod + def create(cls, step_type: str, **kwargs: Any) -> Self: + """ + Instantiate a registered transform by *step_type*. + + Args: + step_type: Name given in :func:`register_transform`. + **kwargs: Arguments forwarded to the transform constructor. + + Returns: + A concrete :class:`NetworkTransform`. + + Raises: + KeyError: If *step_type* is not found. + """ + try: + impl = TRANSFORM_REGISTRY[step_type] + except KeyError as exc: + raise KeyError(f"Unknown transform '{step_type}'.") from exc + return impl(**kwargs) # type: ignore[call-arg] diff --git a/ngraph/transform/distribute_external.py b/ngraph/transform/distribute_external.py new file mode 100644 index 0000000..c434d03 --- /dev/null +++ b/ngraph/transform/distribute_external.py @@ -0,0 +1,120 @@ +""" +Distribute external (remote) nodes across stripes of attachment nodes. + +The transform is generic: + +* ``attachment_path`` - regex that selects any enabled nodes to serve as + attachment points. +* ``remote_locations`` - short names; each is mapped deterministically to + a stripe of attachments. +* ``stripe_width`` - number of attachment nodes per stripe. +* ``capacity`` / ``cost`` - link attributes for created edges. + +Idempotent: re-running the transform will not duplicate nodes or links. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import List, Sequence + +from ngraph.network import Link, Network, Node +from ngraph.scenario import Scenario +from ngraph.transform.base import NetworkTransform, register_transform + + +@dataclass(slots=True) +class _StripeChooser: + """Round-robin stripe selection.""" + + width: int + + def stripes(self, nodes: List[Node]) -> List[List[Node]]: + return [nodes[i : i + self.width] for i in range(0, len(nodes), self.width)] + + def select(self, index: int, stripes: List[List[Node]]) -> List[Node]: + return stripes[index % len(stripes)] + + +@register_transform("DistributeExternalConnectivity") +class DistributeExternalConnectivity(NetworkTransform): + """ + Attach (or create) remote nodes and link them to attachment stripes. + + Args: + remote_locations: Iterable of node names, e.g. ``["den", "sea"]``. + attachment_path: Regex matching nodes that accept the links. + stripe_width: Number of attachment nodes per stripe (≥ 1). + link_count: Number of links per remote node (default ``1``). + capacity: Per-link capacity. + cost: Per-link cost metric. + remote_prefix: Prefix used when creating remote node names (default ``""``). + """ + + def __init__( + self, + remote_locations: Sequence[str], + attachment_path: str, + stripe_width: int, + link_count: int = 1, + capacity: float = 1.0, + cost: float = 1.0, + remote_prefix: str = "", + ) -> None: + if stripe_width < 1: + raise ValueError("stripe_width must be ≥ 1") + self.remotes = list(remote_locations) + self.attachment_path = attachment_path + self.link_count = link_count + self.capacity = capacity + self.cost = cost + self.remote_prefix = remote_prefix + self.chooser = _StripeChooser(width=stripe_width) + self.label = f"Distribute {len(self.remotes)} remotes" + + def apply(self, scenario: Scenario) -> None: + net: Network = scenario.network + + attachments = [ + n + for _, nodes in net.select_node_groups_by_path(self.attachment_path).items() + for n in nodes + if not n.disabled + ] + if not attachments: + raise RuntimeError("No enabled attachment nodes matched.") + + attachments.sort(key=lambda n: n.name) + stripes = self.chooser.stripes(attachments) + + for idx, short in enumerate(self.remotes): + remote = _ensure_remote_node(net, short, self.remote_prefix) + stripe = self.chooser.select(idx, stripes) + _connect_remote( + net, remote, stripe, self.capacity, self.cost, self.link_count + ) + + +def _ensure_remote_node(net: Network, short_name: str, prefix: str) -> Node: + """Return an existing or newly created remote node.""" + full_name = f"{prefix}{short_name}" + if full_name not in net.nodes: + net.add_node(Node(name=full_name, attrs={"type": "remote"})) + return net.nodes[full_name] + + +def _connect_remote( + net: Network, + remote: Node, + stripe: Sequence[Node], + capacity: float, + cost: float, + link_count: int = 1, +) -> None: + """Create links remote → attachment (one-way) if absent.""" + for att in stripe: + # always add new links on each apply; do not re-add remote nodes + for _ in range(link_count): + net.add_link( + Link(source=remote.name, target=att.name, capacity=capacity, cost=cost) + ) diff --git a/ngraph/transform/enable_nodes.py b/ngraph/transform/enable_nodes.py new file mode 100644 index 0000000..b0b45b6 --- /dev/null +++ b/ngraph/transform/enable_nodes.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import itertools +from typing import List + +from ngraph.transform.base import NetworkTransform, register_transform, Scenario +from ngraph.network import Network, Node + + +@register_transform("EnableNodes") +class EnableNodesTransform(NetworkTransform): + """ + Enable *count* disabled nodes that match *path*. + + Ordering is configurable; default is lexical by node name. + """ + + def __init__( + self, + path: str, + count: int, + order: str = "name", # 'name' | 'random' | 'reverse' + ): + self.path = path + self.count = count + self.order = order + self.label = f"Enable {count} nodes @ '{path}'" + + def apply(self, scenario: Scenario) -> None: + net: Network = scenario.network + groups = net.select_node_groups_by_path(self.path) + candidates: List[Node] = [ + n for _lbl, nodes in groups.items() for n in nodes if n.disabled + ] + + if self.order == "reverse": + candidates.sort(key=lambda n: n.name, reverse=True) + elif self.order == "random": + import random as _rnd + + _rnd.shuffle(candidates) + else: # default 'name' + candidates.sort(key=lambda n: n.name) + + for node in itertools.islice(candidates, self.count): + node.disabled = False diff --git a/notebooks/bb_fabric.ipynb b/notebooks/bb_fabric.ipynb new file mode 100644 index 0000000..0a6e19c --- /dev/null +++ b/notebooks/bb_fabric.ipynb @@ -0,0 +1,186 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 6, + "id": "a92a8d34", + "metadata": {}, + "outputs": [], + "source": [ + "from ngraph.scenario import Scenario\n", + "from ngraph.traffic_demand import TrafficDemand\n", + "from ngraph.traffic_manager import TrafficManager\n", + "from ngraph.lib.flow_policy import FlowPolicyConfig, FlowPolicy, FlowPlacement\n", + "from ngraph.lib.algorithms.base import PathAlg, EdgeSelect\n", + "from ngraph.failure_manager import FailureManager\n", + "from ngraph.failure_policy import FailurePolicy, FailureRule, FailureCondition\n", + "from ngraph.explorer import NetworkExplorer" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad94e880", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "- root | Nodes=20, Links=128, Cost=0.0, Power=0.0\n", + " - bb_fabric | Nodes=20, Links=128, Cost=0.0, Power=0.0\n", + " - t2 | Nodes=4, Links=128, Cost=0.0, Power=0.0\n", + " - t1 | Nodes=16, Links=128, Cost=0.0, Power=0.0\n" + ] + } + ], + "source": [ + "scenario_yaml = \"\"\"\n", + "blueprints:\n", + " bb_fabric:\n", + " groups:\n", + " t2:\n", + " node_count: 4 # always on\n", + " name_template: t2-{node_num}\n", + "\n", + " t1:\n", + " node_count: 16 # will be enabled in chunks\n", + " name_template: t1-{node_num}\n", + "\n", + " adjacency: # full mesh, 2 parallel links\n", + " - source: /t1\n", + " target: /t2\n", + " pattern: mesh\n", + " link_count: 2\n", + " link_params:\n", + " capacity: 200\n", + " cost: 1\n", + "\n", + "network:\n", + " name: \"BB_Fabric\"\n", + " version: 1.0\n", + "\n", + " groups:\n", + " bb_fabric:\n", + " use_blueprint: bb_fabric\n", + "\n", + " # disable every T1 at load-time; workflow will enable them in batches\n", + " node_overrides:\n", + " - path: ^bb_fabric/t1/.+\n", + " disabled: true\n", + "\n", + "workflow:\n", + " - step_type: EnableNodes\n", + " path: ^bb_fabric/t1/.+\n", + " count: 4 # enable first group of T1s\n", + " order: name\n", + "\n", + " - step_type: DistributeExternalConnectivity\n", + " remote_prefix: remote/\n", + " remote_locations:\n", + " - LOC1\n", + " attachment_path: ^bb_fabric/t1/.+ # enabled T1 nodes\n", + " stripe_width: 2\n", + " capacity: 800\n", + " cost: 1\n", + "\n", + " - step_type: DistributeExternalConnectivity\n", + " remote_prefix: remote/\n", + " remote_locations:\n", + " - LOC1\n", + " attachment_path: ^bb_fabric/t1/.+ # enabled T1 nodes\n", + " stripe_width: 2\n", + " capacity: 800\n", + " cost: 1\n", + "\"\"\"\n", + "scenario = Scenario.from_yaml(scenario_yaml)\n", + "network = scenario.network\n", + "explorer = NetworkExplorer.explore_network(network, scenario.components_library)\n", + "explorer.print_tree(include_disabled=False, detailed=False, skip_leaves=True)" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "6c491ddc", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Node(name='bb_fabric/t1/t1-4', disabled=True, risk_groups=set(), attrs={'type': 'node'})" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "network.nodes[\"bb_fabric/t1/t1-4\"]" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "df3eb867", + "metadata": {}, + "outputs": [], + "source": [ + "scenario.run()" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "35a81770", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "- root | Nodes=21, Links=130, Cost=0.0, Power=0.0\n", + " - bb_fabric | Nodes=20, Links=130, Cost=0.0, Power=0.0\n", + " - t2 | Nodes=4, Links=128, Cost=0.0, Power=0.0\n", + " - t1 | Nodes=16, Links=130, Cost=0.0, Power=0.0\n", + " - remote | Nodes=1, Links=2, Cost=0.0, Power=0.0\n" + ] + } + ], + "source": [ + "explorer = NetworkExplorer.explore_network(network, scenario.components_library)\n", + "explorer.print_tree(skip_leaves=True, detailed=False)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "aced8d6d", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "ngraph-venv", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebooks/small_demo.ipynb b/notebooks/small_demo.ipynb index 84d1279..ce1f9a1 100644 --- a/notebooks/small_demo.ipynb +++ b/notebooks/small_demo.ipynb @@ -158,9 +158,9 @@ "output_type": "stream", "text": [ "Overall Statistics:\n", - " mean: 215.63\n", - " stdev: 27.45\n", - " min: 179.14\n", + " mean: 206.88\n", + " stdev: 23.54\n", + " min: 178.94\n", " max: 251.57\n" ] } @@ -194,13 +194,13 @@ "name": "stderr", "output_type": "stream", "text": [ - "/var/folders/xh/83kdwyfd0fv66b04mchbfzcc0000gn/T/ipykernel_11568/4192461833.py:60: UserWarning: No artists with labels found to put in legend. Note that artists whose label start with an underscore are ignored when legend() is called with no argument.\n", + "/var/folders/xh/83kdwyfd0fv66b04mchbfzcc0000gn/T/ipykernel_69430/4192461833.py:60: UserWarning: No artists with labels found to put in legend. Note that artists whose label start with an underscore are ignored when legend() is called with no argument.\n", " plt.legend(title=\"Priority\")\n" ] }, { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAmUAAAHWCAYAAAA2Of5hAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlHJYcgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAXDVJREFUeJzt3XdYU2f/P/B3iBCWCAgyFMG9JypFq9AWwVlta92i1lmljxZHxceqaF2tUq111NZVd62zalXcrVI3bq0ijkcBBwoICIHcvz/4ka/HhB3IUd6v6+K6zH3WfT45Sd6eqRBCCBARERGRUZkYuwNERERExFBGREREJAsMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGb5w7d+5AoVBg1apVBp3v1KlToVAoDDrPt5WHhwcGDBhg7G7QGyIuLg7dunVD+fLloVAoMH/+/CLPU+7boLH65+vrC19f3xJfLhkGQ1kptGrVKigUCu2fubk5atasiaCgIMTFxRX78j08PCTLr1ChAlq3bo1t27YV+7ILaubMmdi+fXuxzDsuLg5jx45F7dq1YWlpCSsrK3h6euKbb77B8+fPi2WZpCslJQVTp07FkSNHjN2VHEVFRWHYsGGoWrUqzM3NYWNjg1atWmHBggVITU3VjvfqZ8vExAS2trZo0KABhg4dipMnT+qd96ufxVf/nJ2dDdb/L7/8Evv27UNISAjWrFmDdu3a5Tjuq30wMTGBq6sr/P39Zf3+FMbWrVuhUCjwyy+/5DhOeHg4FAoFfvjhhxLsGRlTGWN3gIxn2rRpqFKlCl6+fIm///4bS5YswZ49e3D58mVYWloW67IbN26MMWPGAAAePnyIn376CR9//DGWLFmC4cOH5zqtu7s7UlNTYWpqatA+TZo0CRMmTJC0zZw5E926dUPXrl0NuqzTp0+jQ4cOePHiBfr27QtPT08AwJkzZzB79mwcO3YM+/fvN+gySb+UlBSEhoYCgCz3MOzevRuffvopVCoVAgMDUb9+faSnp+Pvv//GuHHjcOXKFSxbtkw7/qufraSkJFy7dg2bN2/Gzz//jC+//BJhYWE6y2jbti0CAwMlbRYWFgZbh0OHDqFLly4YO3ZsvsbP7o8QAtHR0Vi8eDHef/997N69G+3btzdYv4ypY8eOKFeuHNavX4/BgwfrHWf9+vVQKpXo2bNnCfeOjIWhrBRr3749mjVrBgAYPHgwypcvj7CwMOzYsQO9evUq0rxTUlJyDXYVK1ZE3759ta8DAwNRvXp1fP/99zmGsoyMDGg0GpiZmcHc3LxI/XtVcnIyrKysUKZMGZQpU/wfiefPn+Ojjz6CUqnE+fPnUbt2bcnwGTNm4Oeffy72flDxyt6uiiI6Oho9e/aEu7s7Dh06BBcXF+2wkSNH4tatW9i9e7dkmtc/WwAwZ84c9O7dG99//z1q1KiBzz//XDK8Zs2aOtMY0qNHj2Bra5vv8V/vz0cffYSGDRti/vz5b00oU6lU6NatG1auXImHDx/C1dVVMvzly5fYtm0b2rZtiwoVKhipl1TSePiStN5//30AWT8E2dauXQtPT09YWFjA3t4ePXv2xP379yXT+fr6on79+jh79izatGkDS0tLTJw4sUDLdnZ2Rp06dbTLzj5vbO7cuZg/fz6qVasGlUqFq1ev5nhO2aFDh9C6dWtYWVnB1tYWXbp0wbVr1yTjZJ83dvXqVfTu3Rt2dnZ49913JcOyKRQKJCcnY/Xq1drDKQMGDMDhw4ehUCj0Hm5dv349FAoFIiIiclzXn376CQ8ePEBYWJhOIAMAJycnTJo0SdK2ePFi1KtXDyqVCq6urhg5cqTOIc7s9+HixYvw8fGBpaUlqlevjt9//x0AcPToUXh5ecHCwgK1atXCgQMH9Nbm+vXr6N69O2xsbFC+fHmMGjUKL1++zHF9sj1//hyjR4+Gm5sbVCoVqlevjjlz5kCj0WjHefV9XbRoEapWrQpLS0v4+/vj/v37EEJg+vTpqFSpEiwsLNClSxfEx8frLOvPP//Uvtdly5ZFx44dceXKFck4AwYMgLW1NR48eICuXbvC2toajo6OGDt2LDIzM7X9cXR0BACEhoZq3+epU6cCAC5evIgBAwZoDxs6Ozvjs88+w9OnT/XW7vXtauXKlVAoFDh//rzOOsycORNKpRIPHjzIsabffvstXrx4geXLl0sCWbbq1atj1KhROU6fzcLCAmvWrIG9vT1mzJgBIUSe0+TH7du38emnn8Le3h6WlpZ45513JCEx+1QJIQQWLVqkrW9BNWjQAA4ODpLvptfFx8dj7NixaNCgAaytrWFjY4P27dvjwoULOuO+fPkSU6dORc2aNWFubg4XFxd8/PHHiIqK0o6j0Wgwf/581KtXD+bm5nBycsKwYcPw7NkzybyEEPjmm29QqVIlWFpa4r333tPZFnPSt29faDQabNy4UWfY7t27kZCQgD59+gDI+k/p9OnTtd+FHh4emDhxItLS0nJdRvZ7cOfOHUn7kSNHoFAoJIeFi/odAgAPHjzAZ599BicnJ6hUKtSrVw8rVqzIVz2IoYxekf2FVL58eQBZe2wCAwNRo0YNhIWFYfTo0Th48CDatGmjEwiePn2K9u3bo3Hjxpg/fz7ee++9Ai1brVbj/v372mVnW7lyJRYuXIihQ4di3rx5sLe31zv9gQMHEBAQgEePHmHq1KkIDg7GiRMn0KpVK50vIwD49NNPkZKSgpkzZ2LIkCF657lmzRqoVCq0bt0aa9aswZo1azBs2DD4+vrCzc0N69at05lm3bp1qFatGry9vXNc1507d8LCwgLdunXLpSL/Z+rUqRg5ciRcXV0xb948fPLJJ/jpp5/g7+8PtVotGffZs2fo1KkTvLy88O2330KlUqFnz57YtGkTevbsiQ4dOmD27NlITk5Gt27dkJSUpLO87t274+XLl5g1axY6dOiAH374AUOHDs21jykpKfDx8cHatWsRGBiIH374Aa1atUJISAiCg4P11mnx4sX44osvMGbMGBw9ehTdu3fHpEmTsHfvXnz11VcYOnQo/vjjD51DXmvWrEHHjh1hbW2NOXPm4Ouvv8bVq1fx7rvv6rzXmZmZCAgIQPny5TF37lz4+Phg3rx52sN9jo6OWLJkCYCsvTHZ7/PHH38MIOucntu3b2PgwIFYuHAhevbsiY0bN6JDhw56g83r21W3bt1gYWGR47bi6+uLihUr5ljXP/74A1WrVkXLli1zrX9+WFtb46OPPsKDBw9w9epVybCXL1/iyZMnkr+8fuzj4uLQsmVL7Nu3DyNGjMCMGTPw8uVLfPjhh9r/sLRp0wZr1qwBkHVIMru+BfXs2TM8e/ZM5/vhVbdv38b27dvRqVMnhIWFYdy4cbh06RJ8fHzw8OFD7XiZmZno1KkTQkND4enpiXnz5mHUqFFISEjA5cuXteMNGzYM48aN0567N3DgQKxbtw4BAQGSz93kyZPx9ddfo1GjRvjuu+9QtWpV+Pv7Izk5Oc/1atOmDSpVqoT169frDFu/fj0sLS21p04MHjwYkydPRtOmTfH999/Dx8cHs2bNMvihzaJ8h8TFxeGdd97BgQMHEBQUhAULFqB69eoYNGiQQS7uKBUElTorV64UAMSBAwfE48ePxf3798XGjRtF+fLlhYWFhfjf//4n7ty5I5RKpZgxY4Zk2kuXLokyZcpI2n18fAQAsXTp0nwt393dXfj7+4vHjx+Lx48fiwsXLoiePXsKAOKLL74QQggRHR0tAAgbGxvx6NEjyfTZw1auXKlta9y4sahQoYJ4+vSptu3ChQvCxMREBAYGatumTJkiAIhevXrp9Ct72KusrKxE//79dcYNCQkRKpVKPH/+XNv26NEjUaZMGTFlypRc19/Ozk40atQo13FenaeZmZnw9/cXmZmZ2vYff/xRABArVqzQtmW/D+vXr9e2Xb9+XQAQJiYm4p9//tG279u3T6eG2ev/4YcfSvowYsQIAUBcuHBB2+bu7i6py/Tp04WVlZX4999/JdNOmDBBKJVKce/ePSHE/713jo6OktqFhIQIAKJRo0ZCrVZr23v16iXMzMzEy5cvhRBCJCUlCVtbWzFkyBDJcmJjY0W5cuUk7f379xcAxLRp0yTjNmnSRHh6empfP378WADQ+76lpKTotG3YsEEAEMeOHdO25bZd9erVS7i6ukrev3PnzunU/3UJCQkCgOjSpUuO47zO3d1ddOzYMcfh33//vQAgduzYoW0DoPcvt74JIcTo0aMFAPHXX39p25KSkkSVKlWEh4eHZH0BiJEjR+ZrHQCIQYMGicePH4tHjx6JkydPig8++EAAEPPmzZOs66vb4MuXLyXLFCJre1OpVJJtYMWKFQKACAsL01m2RqMRQgjx119/CQBi3bp1kuF79+6VtGd/Pjt27KidVgghJk6cKADo/e543bhx4wQAcePGDW1bQkKCMDc3125PkZGRAoAYPHiwZNqxY8cKAOLQoUPaNh8fH+Hj46N9nf19Hx0dLZn28OHDAoA4fPiwZNqifIcMGjRIuLi4iCdPnkiW1bNnT1GuXDm9nyeS4p6yUszPzw+Ojo5wc3NDz549YW1tjW3btqFixYrYunUrNBoNunfvLvnfs7OzM2rUqIHDhw9L5qVSqTBw4MB8L3v//v1wdHSEo6MjGjVqhM2bN6Nfv36YM2eOZLxPPvlEe3gpJzExMYiMjMSAAQMke9IaNmyItm3bYs+ePTrT5HUxQV4CAwORlpam3a0PAJs2bUJGRkae5+YkJiaibNmy+VrOgQMHkJ6ejtGjR8PE5P8+rkOGDIGNjY3O+UTW1taS/znXqlULtra2qFOnDry8vLTt2f++ffu2zjJHjhwpef3FF18AgN46Ztu8eTNat24NOzs7yfbi5+eHzMxMHDt2TDL+p59+inLlyun0p2/fvpLz+ry8vJCenq49xBceHo7nz5+jV69ekuUolUp4eXnpbJeA7nvdunVrveutz6snu2fvTXrnnXcAAOfOnctzWUDWtvLw4UNJ39atWwcLCwt88sknOS47MTERAPK9reSHtbU1AOjsIe3SpQvCw8MlfwEBAbnOa8+ePWjRooX28H/2/IcOHYo7d+7o7I0riOXLl8PR0REVKlSAl5cXjh8/juDgYIwePTrHaVQqlfYzkpmZiadPn8La2hq1atWSvFdbtmyBg4ODdrt+Vfah1c2bN6NcuXJo27atZDvz9PSEtbW19r3M/nx+8cUXksOyufXzddnfF6/uLduyZQtevnypPXSZ/dl7fa9z9gUdr38PFEVhv0OEENiyZQs6d+4MIYSkbgEBAUhISND7mSEpnuhfii1atAg1a9ZEmTJl4OTkhFq1amm/1G7evAkhBGrUqKF32tevfKxYsSLMzMy0rxMSEiSX6puZmUkCk5eXF7755hsoFApYWlqiTp06ek8ErlKlSp7rcffuXQBZXx6vq1OnDvbt26dz0nV+5pub2rVro3nz5li3bh0GDRoEIOuH9p133kH16tVzndbGxkbvYUN9clo3MzMzVK1aVTs8W6VKlXTO2SlXrhzc3Nx02gDonB8DQOc9r1atGkxMTPQeBs528+ZNXLx4MccA/ejRI8nrypUr6+1PXv28efMmgP87//F1NjY2ktfm5uY6fbKzs9O73vrEx8cjNDQUGzdu1FmHhIQEnfH1bVdt27aFi4sL1q1bhw8++AAajQYbNmxAly5dcg1c2euS320lP168eAFAN+hVqlQJfn5+BZrX3bt3JT/S2erUqaMdXr9+/UL1s0uXLggKCoJCoUDZsmVRr169PC+a0Gg0WLBgARYvXozo6GjteYMAJIc9o6KiUKtWrVwv6rl58yYSEhJyPME+e1vI/vy9/plxdHSEnZ1d7iv5/zVs2BD169fHhg0btOcyrl+/Hg4ODtpgfPfuXZiYmOh8tzg7O8PW1lbne6AoCvsd8vjxYzx//hzLli2TXA38qtc/Q6SLoawUa9Gihfbqy9dpNBooFAr8+eefUCqVOsOz/8ed7fXL50eNGoXVq1drX/v4+EhOKHVwcMjXj4AhL8s39HwDAwMxatQo/O9//0NaWhr++ecf/Pjjj3lOV7t2bURGRiI9PV0SZA1B33uVW7vIxwnf+TkxW6PRoG3bthg/frze4TVr1sxXf/LqZ/ZFA2vWrNF7H63Xf2hzml9+de/eHSdOnMC4cePQuHFjWFtbQ6PRoF27dpILGLLp266USiV69+6Nn3/+GYsXL8bx48fx8OHDPPeo2tjYwNXVVXKeU1Flzyuv/zgYW2FC4syZM/H111/js88+w/Tp02Fvbw8TExOMHj1a73uVG41GgwoVKug9FxBAnnvvC6pv376YMGECzpw5g0qVKuHw4cMYNmyYzvZcmIskcprm1dD6qqJ+Nvv27Yv+/fvrHbdhw4a59pUYyigH1apVgxACVapU0flBzY/x48dLfnTy+7/GwnB3dwcA3LhxQ2fY9evX4eDgUOhbE+T2JdizZ08EBwdjw4YN2vum9ejRI895du7cGREREdiyZUuetx55dd2qVq2qbU9PT0d0dHSBf7jy4+bNm5I9Prdu3YJGo4GHh0eO01SrVg0vXrwolv68vhwAqFChgsGWldN7/OzZMxw8eBChoaGYPHmytj17b11BBAYGYt68efjjjz/w559/wtHRMc/DgwDQqVMnLFu2DBEREblePJIfL168wLZt2+Dm5qbdm1UU7u7uOX7msoeXpN9//x3vvfceli9fLml//vw5HBwctK+rVauGkydPQq1W53ivw2rVquHAgQNo1apVrv+By17HmzdvSj6fjx8/zvfeWADo1asXQkJCsH79eri7uyMzM1N76DJ7ORqNBjdv3pS8d3FxcXj+/Hmutc7+7n394ixD7l0DsoJq2bJlkZmZWezfA28znlNGen388cdQKpUIDQ3V2ZsihNC5JcDr6tatCz8/P+1f9s1Ri4OLiwsaN26M1atXS754Ll++jP3796NDhw6FnreVlVWOd9d3cHBA+/btsXbtWqxbtw7t2rWTfPnnZPjw4XBxccGYMWPw77//6gx/9OgRvvnmGwBZ5/2ZmZnhhx9+kLwPy5cvR0JCAjp27Fi4FcvFokWLJK8XLlwIALneH6p79+6IiIjAvn37dIY9f/4cGRkZBulbQEAAbGxsMHPmTJ0rT4GsH8OCyr6f3uvvc/aegde3/8JcRdawYUM0bNgQv/zyC7Zs2YKePXvm655448ePh5WVFQYPHqz3aRtRUVFYsGBBnvNJTU1Fv379EB8fj//+978GeZxYhw4dcOrUKcntX5KTk7Fs2TJ4eHigbt26RV5GQSiVSp33avPmzTq3HPnkk0/w5MkTvXu1s6fv3r07MjMzMX36dJ1xMjIytNuKn58fTE1NsXDhQsmyC7qNVK5cGa1bt8amTZuwdu1aVKlSRXLFbfZ32Ovzzb4RcG7fA9n/kXn1vM7MzMwcDzEWllKpxCeffIItW7bo3btbmM9macQ9ZaRXtWrV8M033yAkJAR37txB165dUbZsWURHR2Pbtm0YOnRovu/OXRK+++47tG/fHt7e3hg0aBBSU1OxcOFClCtXTnueRmF4enriwIEDCAsLg6urK6pUqSI5jyYwMFB7awt9X+D62NnZYdu2bejQoQMaN24suaP/uXPnsGHDBu1eEUdHR4SEhCA0NBTt2rXDhx9+iBs3bmDx4sVo3rx5sdzwMzo6Gh9++CHatWuHiIgIrF27Fr1790ajRo1ynGbcuHHYuXMnOnXqhAEDBsDT0xPJycm4dOkSfv/9d9y5cydfgTUvNjY2WLJkCfr164emTZuiZ8+ecHR0xL1797B79260atUqX4eQX2VhYYG6deti06ZNqFmzJuzt7VG/fn3Ur18fbdq0wbfffgu1Wo2KFSti//79ud4rKzeBgYHaz0x+37dq1aph/fr16NGjB+rUqSO5o/+JEyewefNmnecrPnjwAGvXrgWQtXfs6tWr2Lx5M2JjYzFmzBgMGzasUP1/3YQJE7Bhwwa0b98e//nPf2Bvb4/Vq1cjOjoaW7ZskVyYUhI6deqEadOmYeDAgWjZsiUuXbqEdevWSfZgAVnvw6+//org4GCcOnUKrVu3RnJyMg4cOIARI0agS5cu8PHxwbBhwzBr1ixERkbC398fpqamuHnzJjZv3owFCxagW7du2vvezZo1C506dUKHDh1w/vx5/PnnnwXe3vv27YuhQ4fi4cOH+O9//ysZ1qhRI/Tv3x/Lli3D8+fP4ePjg1OnTmH16tXo2rVrrrcgqlevHt555x2EhIQgPj4e9vb22Lhxo8H+o/Sq2bNn4/Dhw/Dy8sKQIUNQt25dxMfH49y5czhw4IDeew7Sa4xxyScZV/Yl0qdPn85z3C1btoh3331XWFlZCSsrK1G7dm0xcuRIyeXbPj4+ol69evlefl6X7Qvxf7dO+O6773Ic9vol+wcOHBCtWrUSFhYWwsbGRnTu3FlcvXpVMk72rQseP36sM199t8S4fv26aNOmjbCwsNB7iXtaWpqws7MT5cqVE6mpqbmu0+sePnwovvzyS1GzZk1hbm4uLC0thaenp5gxY4ZISEiQjPvjjz+K2rVrC1NTU+Hk5CQ+//xz8ezZM8k4Ob0POdUbr92mIHv9r169Krp16ybKli0r7OzsRFBQkM66vX47AiGybocQEhIiqlevLszMzISDg4No2bKlmDt3rkhPTxdC5Py+Zl+ev3nzZkl7Ttvq4cOHRUBAgChXrpwwNzcX1apVEwMGDBBnzpzRjtO/f39hZWWls9763ucTJ04IT09PYWZmJrk9xv/+9z/x0UcfCVtbW1GuXDnx6aefiocPH+rcQiO37SpbTEyMUCqVombNmjmOk5N///1XDBkyRHh4eAgzMzNRtmxZ0apVK7Fw4ULt7UKEyHpf8P9vaaFQKISNjY2oV6+eGDJkiDh58qTeeb++HRREVFSU6Natm7C1tRXm5uaiRYsWYteuXUVaRn7H1XdLjDFjxggXFxdhYWEhWrVqJSIiInRuESFE1q1O/vvf/4oqVaoIU1NT4ezsLLp16yaioqIk4y1btkx4enoKCwsLUbZsWdGgQQMxfvx48fDhQ+04mZmZIjQ0VLtcX19fcfnyZb2fkdzEx8cLlUql/Qy+Tq1Wi9DQUG2f3dzcREhIiOT9F0L3lhhCZL1Pfn5+QqVSCScnJzFx4kQRHh6u95YYRfkOEUKIuLg4MXLkSOHm5qat7QcffCCWLVuW71qUZgohDHRrZ6JSKCMjA66urujcubPOuSxvmqlTpyI0NBSPHz82yF4tknry5AlcXFy0NxslInodzykjKoLt27fj8ePHOg9zJnrdqlWrkJmZiX79+hm7K0QkUzynjKgQTp48iYsXL2L69Olo0qQJfHx8jN0lkqlDhw7h6tWrmDFjBrp27ZrrVaxEVLoxlBEVwpIlS7B27Vo0btxY58HoRK+aNm2a9jms2VeyEhHpY9Rzyo4dO4bvvvsOZ8+eRUxMDLZt26Z9+GpOjhw5guDgYFy5cgVubm6YNGmSztVHRERERG8ao55TlpycjEaNGuncFykn0dHR6NixI9577z1ERkZi9OjRGDx4sN57IxERERG9SWRz9aVCochzT9lXX32F3bt3S25M17NnTzx//hx79+4tgV4SERERFY836pyyiIgIncc3BAQEYPTo0TlOk5aWhrS0NO1rjUaD+Ph4lC9f3iB3tSYiIiLKjRACSUlJcHV1zfXGym9UKIuNjYWTk5OkzcnJCYmJiUhNTdX7jLJZs2YhNDS0pLpIREREpNf9+/dRqVKlHIe/UaGsMEJCQhAcHKx9nZCQgMqVKyM6Ohply5Y1+PJS0jPQ6tusZ4wdH98Glmb/V2K1Wo3Dhw/jvffey/FBuKUJ6yHFekixHlKshxTrIcV6SMmtHklJSahSpUqeueONCmXOzs46D+WNi4uDjY2N3r1kAKBSqaBSqXTa7e3tYWNjY/A+WqRnwESV9YDj8uXL64QyS0tLlC9fXhYbibGxHlKshxTrIcV6SLEeUqyHlNzqkd2HvE6beqPu6O/t7Y2DBw9K2sLDw7UPbyYiIiJ6Uxk1lL148QKRkZGIjIwEkHXLi8jISNy7dw9A1qHHVx9fM3z4cNy+fRvjx4/H9evXsXjxYvz222/48ssvjdF9IiIiIoMxaig7c+YMmjRpgiZNmgAAgoOD0aRJE0yePBkAEBMTow1oAFClShXs3r0b4eHhaNSoEebNm4dffvkFAQEBRuk/ERERkaEY9ZwyX19f5HabNH2Pr/H19cX58+eLsVdERERkCEIIZGRkIDMzs0SXq1arUaZMGbx8+bJElq1UKlGmTJki32rrjTrRn4iIiN4M6enpiImJQUpKSokvWwgBZ2dn3L9/v8TuSWppaQkXFxeYmZkVeh4MZURERGRQGo0G0dHRUCqVcHV1hZmZWYnesF2j0eDFixewtrbO9WathiCEQHp6Oh4/fozo6GjUqFGj0MtkKCMiIiKDSk9Ph0ajgZubGywtLUt8+RqNBunp6TA3Ny/2UAYAFhYWMDU1xd27d7XLLYw36pYYRERE9OYoiUAkF4ZYV+4pK0Yp6dKTC9XqDKRlZt3131TwuZtqdQZyuc6DiIioVGEoK0bNvjmgp7UMxp86VOJ9kasqZZXo0IHJjIiIqPTsVywhFqZKNHO3M3Y33hjRSQqkqkv2UmkiInrzeHh4YP78+UWej6+vL0aPHl3k+RQH7ikzMIVCgc3DvfUGDbVajX379iMgwF8Wz+IyppT0zBz2JBIR0dtuwIABWL16NYCs50JWrlwZgYGBmDhxIsqU0R9NTp8+DSsrqyIve+vWrZLfYA8PD4wePVoWQY2hrBgoFArJg8izqRUCKiVgaVYGpqYsPRERlV7t2rXDypUrkZaWhj179mDkyJEwNTVFSEiIZLz09HSYmZnB0dGxSMvLno+9vX2R5lOcePiSiIiISpxKpYKzszPc3d3x+eefw8/PDzt37sSAAQPQtWtXzJgxA66urqhVqxYA3cOX9+7dQ5cuXWBtbQ0bGxt0794dcXFx2uGzZ89G06ZN8csvv6BKlSra21S8evjS19cXd+/exZdffgmFQgGFQoHk5GTY2Njg999/l/R3+/btsLKyQlJSUrHVhKGMiIiIjM7CwgLp6ekAgIMHD+LGjRsIDw/Hrl27dMbVaDTo0qUL4uPjcfToUYSHh+P27dvo0aOHZLxbt25hy5Yt2Lp1KyIjI3Xms3XrVlSqVAnTpk1DTEwMYmJiYGVlhZ49e2LlypWScVeuXIlu3bqhbNmyhlvp1/AYGhERERmNEAIHDx7Evn378MUXX+Dx48ewsrLCL7/8kuMjiw4ePIhLly4hOjoabm5uAIBff/0V9erVw+nTp+Hp6Qkg65Dlr7/+muOhT3t7eyiVSpQtWxbOzs7a9sGDB6Nly5aIiYmBi4sLHj16hD179uDAgeI9F5p7yoiIiKjE7dq1C9bW1jA3N0f79u3Ro0cPTJ06FQDQoEGDXJ8hee3aNbi5uWkDGQDUrVsXtra2uHbtmrbN3d29UOeitWjRAvXq1dNejLB27Vq4u7ujTZs2BZ5XQTCUERERUYl77733EBkZiZs3byI1NRWrV6/WXl1piKssizqfwYMHY9WqVQCyDl0OHDiw2J/fyVBGREREJc7KygrVq1dH5cqVc7wNRk7q1KmD+/fv4/79+9q2q1ev4vnz56hbt26B5mVmZobMTN3bWPXt2xd3797FDz/8gKtXr6J///4Fmm9hMJQRERHRG8XPzw8NGjRAnz59cO7cOZw6dQqBgYHw8fFBs2bNCjQvDw8PHDt2DA8ePMCTJ0+07XZ2dvj4448xbtw4+Pv7o1KlSoZeDR0MZWR0qemZEHwIJhER5ZNCocCOHTtgZ2eHNm3awM/PD1WrVsWmTZsKPK9p06bhzp07qFatms75Z4MGDUJ6ejo+++wzQ3U9V7z6kozunTlH0czdDpuHexf78XoiIjK+7HO1CjLszp07kteVK1fGjh07cpzPhAkTMHPmTJ32I0eOSF6/8847uHDhgt55PHjwAOXLl0eXLl1yXI4hcU8ZGYWFqRKelW21r8/cfcZnYBIRkSykpKQgKioKs2fPxrBhw3K9EtSQGMrIKBQKBTYMbo5vmmUYuytEREQS3377LWrXrg1nZ2edxz4VJ4YyMhqFQgEzboFERCQzU6dOhVqtxsGDB2FtbV1iy+VPIhEREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGRERERULIYSxu1BiDLGuDGVERERkUKampgCyniFZWmSva/a6F0YZQ3WGiIiICACUSiVsbW3x6NEjAIClpSUUCkWJLV+j0SA9PR0vX76EiUnx7n8SQiAlJQWPHj2Cra0tlEploefFUEZEREQG5+zsDADaYFaShBBITU2FhYVFiYVBW1tb7ToXFkMZERERGZxCoYCLiwsqVKgAtVpdostWq9U4duwY2rRpU6TDifllampapD1k2RjKiIiIqNgolUqDBJaCLjMjIwPm5uYlEsoMhSf6ExEREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREcmA0UPZokWL4OHhAXNzc3h5eeHUqVO5jj9//nzUqlULFhYWcHNzw5dffomXL1+WUG+JiIiIiodRQ9mmTZsQHByMKVOm4Ny5c2jUqBECAgLw6NEjveOvX78eEyZMwJQpU3Dt2jUsX74cmzZtwsSJE0u450RERESGZdRQFhYWhiFDhmDgwIGoW7culi5dCktLS6xYsULv+CdOnECrVq3Qu3dveHh4wN/fH7169cpz7xoRERGR3JUx1oLT09Nx9uxZhISEaNtMTEzg5+eHiIgI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ExERERmM0UMZAAQFBSEoKEjvsCNHjkhe37lzp/g7RERERFTCjH7zWCIiIiJiKCMiIiKSBYYyIiIiIhlgKCMiIiKSAYYyIiIiKtWM98BJKYYyIiIiKtU+XRoBIz4KXIuhjIiIiEodC1Ml6rpkPQP7akwiUtWZRu4RQxkRERGVQgqFApuH5/xIR2NgKCMiIqJSSaHIe5ySxFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAMMZUREREQywFBGREREJAOyCGWLFi2Ch4cHzM3N4eXlhVOnTuU6/ubNm1G7dm2Ym5ujQYMG2LNnTwn1lIiIiKh4GD2Ubdq0CcHBwZgyZQrOnTuHRo0aISAgAI8ePdI7/okTJ9CrVy8MGjQI58+fR9euXdG1a1dcvny5hHtOREREZDhGD2VhYWEYMmQIBg4ciLp162Lp0qWwtLTEihUr9I6/YMECtGvXDuPGjUOdOnUwffp0NG3aFD/++GMJ95yIiIjIcMoYc+Hp6ek4e/YsQkJCtG0mJibw8/NDRESE3mkiIiIQHBwsaQsICMD27dv1jp+Wloa0tDTt64SEBABAfHw81Gp1EdegYNRqNVJSUvD06VOYmpqW6LLliPWQYj2kWA8p1kOK9ZBiPaTyW4+U9Axo0lIAAE+fPkWqWfHEoqSkJACAECLX8Ywayp48eYLMzEw4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", + "image/png": "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uteJiYkAAI1GA41GY6S1ybmMZZpi2XL0Zh00Gg00CmGi3pgetw8p1kOK9ZBiPaRYj1fkVouc9sNkoezhw4dIT0+Hi4uLpN3FxQWXL182OE23bt3w8OFDvPvuuxBCIC0tDYMGDcry8OW0adMQFham1757925YWVnlbyXyISIiwmTLlrNdu3ZDrTR1L0yP24cU6yHFekixHlKsxytyqUVycnKOxjNZKMuLAwcOYOrUqZg/fz58fHxw7do1DBs2DJMnT8bXX39tcJrQ0FCEhIToXicmJsLDwwMBAQGwtbUtrK7raDQaREREoGXLllCpVIW+fLnRaDTYtvPVhyYwMABW5m/VZmlU3D6kWA8p1kOK9ZBiPV6RWy0yjtJlx2S/fk5OTlAqlYiLi5O0x8XFwdXV1eA0X3/9NXr27Il+/foBAGrXro2kpCQMGDAA//3vf2Fmpn+KnFqthlqt1mtXqVQmfaNMvXy5elmX4hvKMnD7kGI9pFgPKdZDivV4RS61yGkfTHaiv7m5Oby9vbF3715dm1arxd69e+Hr62twmuTkZL3gpVS+PNYlRPE9D4mIiIjefibdJRESEoJevXqhYcOGaNy4MWbPno2kpCT06dMHABAcHIwyZcpg2rRpAICgoCCEh4ejfv36usOXX3/9NYKCgnThjIiIiOhtZNJQ1rlzZzx48ADjx49HbGws6tWrh507d+pO/r99+7Zkz9i4ceOgUCgwbtw43L17F87OzggKCsKUKVNMtQpERERERmHyk3eGDh2KoUOHGhx24MAByesSJUpgwoQJmDBhQiH0jIiIiKjwmPwxS0RERETEUEZEREQkCwxlRERERDLAUEZEREQkAwxlJCu83RwRERVXDGUkK58ujOSNgImIqFhiKCOTMzcDqruWBABcjEnEc026iXtERERU+BjKyOQUCmBNv0am7gYREZFJMZSRLCgUpu4BERGRaTGUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDJQwdQeI3pScmp6j8SxVSigUigLuDRERUeFgKCPZafjNnpyN5+mA9YN8GcyIiKhI4OFLkgVLlRINPR1yNc2JW4/xXJOzvWpERERyxz1lJAsKhQLrB/nmKGQlp6bneG8aERHR24KhjGRDoVDAypybJBERFU88fElEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDLAUEZEREQkAwxlRERERDJg8lA2b948eHl5wcLCAj4+Pjh27FiW4z958gRDhgyBm5sb1Go1qlSpgh07dhRSb4mIiIgKRglTLnzdunUICQnBwoUL4ePjg9mzZyMwMBBXrlxB6dKl9cZPTU1Fy5YtUbp0afz+++8oU6YMbt26BXt7+8LvPBEREZERmTSUhYeHo3///ujTpw8AYOHChdi+fTuWLFmCMWPG6I2/ZMkSxMfH48iRI1CpVAAALy+vwuwyERERUYEwWShLTU3FyZMnERoaqmszMzODv78/IiMjDU6zdetW+Pr6YsiQIdiyZQucnZ3RrVs3fPXVV1AqlQanSUlJQUpKiu51YmIiAECj0UCj0RhxjXImY5mmWLYc5aUeGk2aZHqNQhi9X6bC7UOK9ZBiPaRYDynW4xW51SKn/TBZKHv48CHS09Ph4uIiaXdxccHly5cNTnPjxg3s27cP3bt3x44dO3Dt2jUMHjwYGo0GEyZMMDjNtGnTEBYWpte+e/duWFlZ5X9F8igiIsJky5aj3NQjJR3I2HR37doNteE8/lbj9iHFekixHlKshxTr8YpcapGcnJyj8Ux6+DK3tFotSpcujUWLFkGpVMLb2xt3797Fd999l2koCw0NRUhIiO51YmIiPDw8EBAQAFtb28Lquo5Go0FERARatmypOwRbnOWlHsmpaRh9bB8AIDAwAFbmb9VmnCVuH1KshxTrIcV6SLEer8itFhlH6bJjsl8zJycnKJVKxMXFSdrj4uLg6upqcBo3NzeoVCrJocrq1asjNjYWqampMDc315tGrVZDrVbrtatUKpO+UaZevtzkph4qoXhjuqITyjJw+5BiPaRYDynWQ4r1eEUutchpH0x2Swxzc3N4e3tj7969ujatVou9e/fC19fX4DRNmzbFtWvXoNVqdW3//vsv3NzcDAYyIiIioreFSe9TFhISgp9//hnLly/HpUuX8PnnnyMpKUl3NWZwcLDkQoDPP/8c8fHxGDZsGP79919s374dU6dOxZAhQ0y1CkRERERGYdLjPp07d8aDBw8wfvx4xMbGol69eti5c6fu5P/bt2/DzOxVbvTw8MCuXbvw5Zdfok6dOihTpgyGDRuGr776ylSrQERERGQUJj8ZZ+jQoRg6dKjBYQcOHNBr8/X1xT///FPAvSIiIiIqXCZ/zBIRERERMZQRERERyQJDGREREZEMMJQRERERyQBDGREREZEM5PnqS41Gg9jYWCQnJ8PZ2RmOjo7G7BcRERFRsZKrPWVPnz7FggUL4OfnB1tbW3h5eaF69epwdnaGp6cn+vfvj+PHjxdUX4mIiIiKrByHsvDwcHh5eWHp0qXw9/fH5s2bERUVhX///ReRkZGYMGEC0tLSEBAQgFatWuHq1asF2W8iIiKiIiXHhy+PHz+OQ4cOoWbNmgaHN27cGJ999hkWLlyIpUuX4q+//kLlypWN1lEiIiKioizHoWzNmjU5Gk+tVmPQoEF57hARERFRcZSnqy8fPHiQ6bBz587luTNERERExVWeQlnt2rWxfft2vfaZM2eicePG+e4UERERUXGTp1AWEhKCTz75BJ9//jmeP3+Ou3fv4oMPPsC3336L1atXG7uPREREREVenkLZ6NGjERkZib/++gt16tRBnTp1oFarcfbsWXz00UfG7iMRERFRkZfnO/pXqlQJtWrVws2bN5GYmIjOnTvD1dXVmH0jIiIiKjbyFMoOHz6MOnXq4OrVqzh79iwWLFiAL774Ap07d8bjx4+N3UciIiKiIi9Poez9999H586d8c8//6B69ero168fTp8+jdu3b6N27drG7iMRERFRkZenZ1/u3r0bfn5+kraKFSvi8OHDmDJlilE6RkRERFSc5GlP2ZuBTDczMzN8/fXX+eoQERERUXGU5xP9iYiIiMh4GMqIiIiIZIChjIiIiEgGGMqIiIiIZIChjIiIiEgG8hXKOnTogC+++EL3+vr163B3d893p4iIiIiKmzyHsoSEBOzYsQPr16/XtaWlpSEuLs4oHSMiIiIqTvIcynbv3g1XV1ckJyfj+PHjxuwTERERUbGT51C2Y8cOtG3bFu+//z527NhhzD4RERERFTt5DmW7du1Cu3bt0KZNG4YyIiIionzKUyg7efIkEhIS8MEHH6B169Y4deoUHj58aOy+ERERERUbeQplO3bsQIsWLWBhYQEPDw9Uq1YNO3fuNHbfiIiIiIqNPIeytm3b6l63adMG27dvN1qniIiIiIqbXIey58+fQ6lUol27drq2jz/+GAkJCbC0tETjxo2N2kEiIiKi4qBEbiewtLTE33//LWnz8fHRnewfGRlpnJ4RERERFSN8zBIRERGRDDCUEREREckAQxkRERGRDDCUEREREckAQxkRERGRDOQqlC1evDjL4U+fPkW/fv3y1SEiIiKi4ihXoSwkJATt2rVDbGys3rBdu3ahZs2aOH78uNE6R0RERFRc5CqUnTlzBklJSahZsybWrFkD4OXesb59+yIoKAg9evTAiRMnCqSjREREREVZrm4e6+Xlhf3792P27Nno378/Vq1ahXPnzsHGxgaHDx9Go0aNCqqfREREREVaru/oDwADBw7EoUOHsHnzZlhbW2Pbtm2oXbu2sftGREREVGzk+urLw4cPo27durh8+TJ27tyJ1q1bw9fXF3PmzCmI/hEREREVC7kKZSNGjMD777+PoKAgnDp1CgEBAfjtt9+wePFifPPNN2jRogWio6MLqq9ERERERVauQtmWLVuwZ88ezJo1CxYWFrr2zp074/z587Czs0OdOnWM3kkiIiKioi5X55SdPXsWVlZWBoe5uLhgy5YtWLFihVE6RkRERFSc5GpPWWaB7HU9e/bMc2eIiIiIiqsch7Lp06cjOTk5R+MePXoU27dvz3OniIiIiIqbHIeyixcvwtPTE4MHD8aff/6JBw8e6IalpaXh7NmzmD9/Ppo0aYLOnTujZMmSBdJhIiIioqIox+eU/frrrzhz5gx+/PFHdOvWDYmJiVAqlVCr1bo9aPXr10e/fv3Qu3dvyYUARERERJS1XJ3oX7duXfz888/46aefcObMGdy+fRvPnz+Hk5MT6tWrBycnp4LqJxEREVGRlqc7+puZmaF+/fqoX7++sftDREREVCzl6urL9PR0zJgxA02bNkWjRo0wZswYPH/+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YKlSoqGnQ6bDT9x6jOca/T1oVHRwTxkREZEMKBQKrB/kqxe8klPTDe45o6KHoYyIiEgmFAoFrMz501xc8fAlERERkQwwlBERERHJAEMZERERkQwwlBERERHJAEMZERERkQwwlBERERHJAEMZERERkQzwZihERERvCUPPxMyg0aQhJR1ITk2DSuTuUUx8rqY8MJQRERG9JbK/s38JjD62L/fz5XM1ZYGHL4mIiGQsu2diGgOfqykP3FNGREQkY5k9E/NNGo0Gu3btRmBgAFQqVY7mzedqygtDGRERkczl5JmYGoWAWglYmZeASsWf97cRD18SERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEMMJQRERERyQBDGREREZEM8O5yRERElOXDzo2FDz7PGkMZERERFcrjlvjg86zx8CUREVExVRgPO38dH3yeNe4pIyIiKqZy+rDz/OKDz3OGoYyIiKgYy8nDzqlw8PAlERERkQwwlBERERHJAEMZERERkQwwlBERERHJAEMZERERkQwwlBERERHJAEMZERERkQwwlBERERHJAO8WR0RERIWmMB58rtGkISUdSE5Ng0pk/ZxNOT0knaGMiIiICk3hPW6pBEYf25ftWHJ6SDoPXxIREVGBKuwHn+eGnB6Szj1lREREVKAK68HnGTQaDXbt2o3AwACoVCqD48jxIekMZURERFTgCvPB5xqFgFoJWJmXgEr19kQdHr4kIiIikgGGMiIiIiIZYCgjIiIikgGGMiIiIiIZYCgjIiIikgGGMiIiIiIZYCgjIiIikgGGMiIiIiIZYCgjIiKiYk0IU/fgJYYyIiIiKtY+XRgJIYNkxlBGRERExY6lSokabrYAgIsxibJ4KDlDGRERERU7GQ9JlxOGMiIiIiqWFApT90CKoYyIiIhIBhjKiIiIiGSAoYyIiIhIBhjKiIiIiGSAoYyIiIhIBhjKiIiIiGRAFqFs3rx58PLygoWFBXx8fHDs2LEsx1+/fj2qVasGCwsL1K5dGzt27CiknhIREREVDJOHsnXr1iEkJAQTJkzAqVOnULduXQQGBuL+/fsGxz9y5Ai6du2Kvn374vTp0+jQoQM6dOiA8+fPF3LPiYiIiIzH5KEsPDwc/fv3R58+fVCjRg0sXLgQVlZWWLJkicHx58yZg1atWmHUqFGoXr06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", "text/plain": [ "
" ] @@ -300,7 +300,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.1" + "version": "3.13.3" } }, "nbformat": 4, diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 03952c7..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -geopy -networkx -pyyaml \ No newline at end of file diff --git a/tests/transform/__init__.py b/tests/transform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/transform/test_base.py b/tests/transform/test_base.py new file mode 100644 index 0000000..54e824b --- /dev/null +++ b/tests/transform/test_base.py @@ -0,0 +1,33 @@ +import pytest +from ngraph.transform.base import ( + TRANSFORM_REGISTRY, + register_transform, + NetworkTransform, +) + + +def test_registry_contains_transforms(): + assert "EnableNodes" in TRANSFORM_REGISTRY + assert "DistributeExternalConnectivity" in TRANSFORM_REGISTRY + + +def test_create_known_transform(): + transform = NetworkTransform.create("EnableNodes", path="dummy", count=1) + from ngraph.transform.enable_nodes import EnableNodesTransform + + assert isinstance(transform, EnableNodesTransform) + + +def test_create_unknown_transform(): + with pytest.raises(KeyError) as exc: + NetworkTransform.create("NoSuch", foo=1) + assert "Unknown transform 'NoSuch'" in str(exc.value) + + +def test_register_duplicate_name_raises(): + with pytest.raises(ValueError): + + @register_transform("EnableNodes") + class DummyTransform(NetworkTransform): + def apply(self, scenario): + pass diff --git a/tests/transform/test_distribute_external.py b/tests/transform/test_distribute_external.py new file mode 100644 index 0000000..7c51193 --- /dev/null +++ b/tests/transform/test_distribute_external.py @@ -0,0 +1,99 @@ +import pytest +from ngraph.transform.distribute_external import ( + _StripeChooser, + DistributeExternalConnectivity, +) +from ngraph.network import Network, Node, Link +from ngraph.scenario import Scenario + + +def make_scenario_with_network(net): + return Scenario(network=net, failure_policy=None, traffic_demands=[], workflow=[]) + + +def test_stripe_chooser_stripes_and_select(): + nodes = [Node(name=f"n{i}") for i in range(5)] + chooser = _StripeChooser(width=3) + stripes = chooser.stripes(nodes) + assert len(stripes) == 2 + assert [n.name for n in stripes[0]] == ["n0", "n1", "n2"] + assert [n.name for n in stripes[1]] == ["n3", "n4"] + # select round-robin + assert chooser.select(0, stripes) == stripes[0] + assert chooser.select(1, stripes) == stripes[1] + assert chooser.select(2, stripes) == stripes[0] + + +def test_invalid_stripe_width(): + with pytest.raises(ValueError): + DistributeExternalConnectivity( + remote_locations=["r"], attachment_path=".*", stripe_width=0 + ) + + +def test_apply_no_attachments_raises(): + net = Network() + scenario = make_scenario_with_network(net) + transform = DistributeExternalConnectivity( + remote_locations=["r"], attachment_path="^a", stripe_width=1 + ) + with pytest.raises(RuntimeError): + transform.apply(scenario) + + +def test_basic_distribution_and_idempotence(): + net = Network() + # create 4 attachment nodes + for i in range(1, 5): + net.add_node(Node(name=f"a{i}")) + scenario = make_scenario_with_network(net) + transform = DistributeExternalConnectivity( + remote_locations=["r1", "r2"], + attachment_path="^a", + stripe_width=2, + link_count=1, + capacity=5.0, + cost=10.0, + remote_prefix="p-", + ) + # first apply + transform.apply(scenario) + # remote nodes created + assert "p-r1" in net.nodes + assert "p-r2" in net.nodes + # links created correctly + links = [] + for r, stripe in [("p-r1", ["a1", "a2"]), ("p-r2", ["a3", "a4"])]: + for a in stripe: + ids = net.get_links_between(r, a) + assert len(ids) == 1 + link = net.links[ids[0]] + assert link.capacity == 5.0 + assert link.cost == 10.0 + links.extend(ids) + assert len(links) == 4 + # second apply should add additional links but not more nodes + transform.apply(scenario) + assert len(net.nodes) == 6 # 4 attachments + 2 remotes + # nodes unchanged, but links doubled + total_links = sum( + len(net.get_links_between(r, a)) for r, a in [("p-r1", "a1"), ("p-r2", "a4")] + ) + assert total_links == 4 + + +def test_link_count_multiple(): + net = Network() + for i in range(1, 3): + net.add_node(Node(name=f"a{i}")) + scenario = make_scenario_with_network(net) + transform = DistributeExternalConnectivity( + remote_locations=["r"], + attachment_path="^a", + stripe_width=2, + link_count=2, + ) + transform.apply(scenario) + # default prefix "" so remote named 'r' + ids = net.get_links_between("r", "a1") + assert len(ids) == 2 diff --git a/tests/transform/test_enable_nodes.py b/tests/transform/test_enable_nodes.py new file mode 100644 index 0000000..6cc41c2 --- /dev/null +++ b/tests/transform/test_enable_nodes.py @@ -0,0 +1,62 @@ +import pytest +from ngraph.network import Network, Node +from ngraph.scenario import Scenario +from ngraph.transform.enable_nodes import EnableNodesTransform +import ngraph.transform.enable_nodes as en_mod +import random + + +def make_scenario(nodes): + net = Network() + for name, disabled in nodes: + net.add_node(Node(name=name, disabled=disabled)) + return Scenario(network=net, failure_policy=None, traffic_demands=[], workflow=[]) + + +def test_default_order_enables_lexical_nodes(): + scenario = make_scenario([("b", True), ("a", True), ("c", True)]) + transform = EnableNodesTransform(path="^.", count=2) + assert transform.label == "Enable 2 nodes @ '^.'" + transform.apply(scenario) + net = scenario.network + assert not net.nodes["a"].disabled + assert not net.nodes["b"].disabled + assert net.nodes["c"].disabled + + +def test_reverse_order_enables_highest_name(): + scenario = make_scenario([("a", True), ("b", True), ("c", True)]) + transform = EnableNodesTransform(path="^.", count=1, order="reverse") + transform.apply(scenario) + net = scenario.network + assert not net.nodes["c"].disabled + assert net.nodes["a"].disabled + assert net.nodes["b"].disabled + + +def test_random_order_enables_shuffled_node(monkeypatch): + scenario = make_scenario([("a", True), ("b", True), ("c", True)]) + + # patch shuffle to reverse order + def fake_shuffle(lst): + lst.reverse() + + monkeypatch.setattr(random, "shuffle", fake_shuffle) + transform = EnableNodesTransform(path="^.", count=1, order="random") + transform.apply(scenario) + net = scenario.network + # after fake shuffle, 'c' is first + assert not net.nodes["c"].disabled + assert net.nodes["a"].disabled + assert net.nodes["b"].disabled + + +def test_no_matching_nodes_does_nothing(): + scenario = make_scenario([("x", False), ("y", True)]) + transform = EnableNodesTransform(path="^z", count=1) + # should not raise + transform.apply(scenario) + net = scenario.network + # original states remain + assert not net.nodes["x"].disabled + assert net.nodes["y"].disabled From 787630c4425142d7ab2fef46d90958bbf6af62c7 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 17 May 2025 22:14:41 -0700 Subject: [PATCH 2/4] Fix floating point rounding in Demand.place (#65) --- ngraph/lib/demand.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/ngraph/lib/demand.py b/ngraph/lib/demand.py index 37d094c..a2b06da 100644 --- a/ngraph/lib/demand.py +++ b/ngraph/lib/demand.py @@ -1,8 +1,10 @@ from __future__ import annotations +import math from dataclasses import dataclass, field from typing import Optional, Tuple +from ngraph.lib.algorithms.base import MIN_FLOW from ngraph.lib.flow_policy import FlowPolicy from ngraph.lib.graph import NodeID, StrictMultiDiGraph @@ -21,6 +23,16 @@ class Demand: flow_policy: Optional[FlowPolicy] = None placed_demand: float = field(default=0.0, init=False) + @staticmethod + def _round_float(value: float) -> float: + """Round ``value`` to avoid tiny floating point drift.""" + if math.isfinite(value): + rounded = round(value, 12) + if abs(rounded) < MIN_FLOW: + return 0.0 + return rounded + return value + def __lt__(self, other: Demand) -> bool: """ Compare Demands by their demand_class (priority). A lower demand_class @@ -94,7 +106,10 @@ def place( # placed_now is the difference from the old placed_demand placed_now = self.flow_policy.placed_demand - self.placed_demand - self.placed_demand = self.flow_policy.placed_demand + self.placed_demand = self._round_float(self.flow_policy.placed_demand) remaining = to_place - placed_now + placed_now = self._round_float(placed_now) + remaining = self._round_float(remaining) + return placed_now, remaining From 19eb47ce1ca9c324bad6ceaad95a2da6616a31a1 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 17 May 2025 22:59:05 -0700 Subject: [PATCH 3/4] Add CLI tool with run command (#67) --- ngraph/__init__.py | 5 ++++- ngraph/__main__.py | 6 +++++ ngraph/cli.py | 47 +++++++++++++++++++++++++++++++++++++++ ngraph/results.py | 4 ++++ notebooks/bb_fabric.ipynb | 20 ++++++++--------- pyproject.toml | 3 +++ tests/test_cli.py | 22 ++++++++++++++++++ 7 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 ngraph/__main__.py create mode 100644 ngraph/cli.py create mode 100644 tests/test_cli.py diff --git a/ngraph/__init__.py b/ngraph/__init__.py index c8163b1..bb67ad8 100644 --- a/ngraph/__init__.py +++ b/ngraph/__init__.py @@ -1 +1,4 @@ -import ngraph.transform +from __future__ import annotations +from . import cli, transform + +__all__ = ["cli", "transform"] diff --git a/ngraph/__main__.py b/ngraph/__main__.py new file mode 100644 index 0000000..fc97e39 --- /dev/null +++ b/ngraph/__main__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from .cli import main + +if __name__ == "__main__": + main() diff --git a/ngraph/cli.py b/ngraph/cli.py new file mode 100644 index 0000000..932fe44 --- /dev/null +++ b/ngraph/cli.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any, Dict, List, Optional + +from ngraph.scenario import Scenario + + +def _run_scenario(path: Path, output: Optional[Path]) -> None: + """Run a scenario file and store results as JSON.""" + yaml_text = path.read_text() + scenario = Scenario.from_yaml(yaml_text) + scenario.run() + + results_dict: Dict[str, Dict[str, Any]] = scenario.results.to_dict() + json_str = json.dumps(results_dict, indent=2, default=str) + if output: + output.write_text(json_str) + else: + print(json_str) + + +def main(argv: Optional[List[str]] = None) -> None: + """Entry point for the ``ngraph`` command.""" + parser = argparse.ArgumentParser(prog="ngraph") + subparsers = parser.add_subparsers(dest="command", required=True) + + run_parser = subparsers.add_parser("run", help="Run a scenario") + run_parser.add_argument("scenario", type=Path, help="Path to scenario YAML") + run_parser.add_argument( + "--results", + "-r", + type=Path, + default=None, + help="Write JSON results to this file instead of stdout", + ) + + args = parser.parse_args(argv) + + if args.command == "run": + _run_scenario(args.scenario, args.results) + + +if __name__ == "__main__": + main() diff --git a/ngraph/results.py b/ngraph/results.py index 174d815..5422045 100644 --- a/ngraph/results.py +++ b/ngraph/results.py @@ -54,3 +54,7 @@ def get_all(self, key: str) -> Dict[str, Any]: if key in data: result[step_name] = data[key] return result + + def to_dict(self) -> Dict[str, Dict[str, Any]]: + """Return a dictionary representation of all stored results.""" + return {step: data.copy() for step, data in self._store.items()} diff --git a/notebooks/bb_fabric.ipynb b/notebooks/bb_fabric.ipynb index 0a6e19c..feb61fd 100644 --- a/notebooks/bb_fabric.ipynb +++ b/notebooks/bb_fabric.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 6, + "execution_count": 11, "id": "a92a8d34", "metadata": {}, "outputs": [], @@ -19,7 +19,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "id": "ad94e880", "metadata": {}, "outputs": [ @@ -101,7 +101,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 13, "id": "6c491ddc", "metadata": {}, "outputs": [ @@ -111,7 +111,7 @@ "Node(name='bb_fabric/t1/t1-4', disabled=True, risk_groups=set(), attrs={'type': 'node'})" ] }, - "execution_count": 8, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } @@ -122,7 +122,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 14, "id": "df3eb867", "metadata": {}, "outputs": [], @@ -132,7 +132,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 15, "id": "35a81770", "metadata": {}, "outputs": [ @@ -140,11 +140,11 @@ "name": "stdout", "output_type": "stream", "text": [ - "- root | Nodes=21, Links=130, Cost=0.0, Power=0.0\n", - " - bb_fabric | Nodes=20, Links=130, Cost=0.0, Power=0.0\n", + "- root | Nodes=21, Links=132, Cost=0.0, Power=0.0\n", + " - bb_fabric | Nodes=20, Links=132, Cost=0.0, Power=0.0\n", " - t2 | Nodes=4, Links=128, Cost=0.0, Power=0.0\n", - " - t1 | Nodes=16, Links=130, Cost=0.0, Power=0.0\n", - " - remote | Nodes=1, Links=2, Cost=0.0, Power=0.0\n" + " - t1 | Nodes=16, Links=132, Cost=0.0, Power=0.0\n", + " - remote | Nodes=1, Links=4, Cost=0.0, Power=0.0\n" ] } ], diff --git a/pyproject.toml b/pyproject.toml index 88212c0..bc22bb6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,9 @@ dev = [ "black", "isort", ] +[project.scripts] +ngraph = "ngraph.cli:main" + # --------------------------------------------------------------------- # Pytest flags diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a6802e1 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,22 @@ +import json +from pathlib import Path + +from ngraph import cli + + +def test_cli_run_file(tmp_path: Path) -> None: + scenario = Path("tests/scenarios/scenario_1.yaml") + out_file = tmp_path / "res.json" + cli.main(["run", str(scenario), "--results", str(out_file)]) + assert out_file.is_file() + data = json.loads(out_file.read_text()) + assert "build_graph" in data + assert "graph" in data["build_graph"] + + +def test_cli_run_stdout(capsys) -> None: + scenario = Path("tests/scenarios/scenario_1.yaml") + cli.main(["run", str(scenario)]) + captured = capsys.readouterr() + data = json.loads(captured.out) + assert "build_graph" in data From 6b4e44c79c94b59596cbe534aa118ea6001da286 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Mon, 2 Jun 2025 18:17:51 +0100 Subject: [PATCH 4/4] adding transforms --- ngraph/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/ngraph/__init__.py b/ngraph/__init__.py index bb67ad8..6cf9a8a 100644 --- a/ngraph/__init__.py +++ b/ngraph/__init__.py @@ -1,4 +1,5 @@ from __future__ import annotations from . import cli, transform + __all__ = ["cli", "transform"]