Skip to content

Commit 87e0a04

Browse files
authored
Merge pull request #42 from networmix/network
Add initial implementations for Scenario, Network, and their components.
2 parents f1c1431 + ffe09ec commit 87e0a04

25 files changed

Lines changed: 1639 additions & 433 deletions

ngraph/failure_policy.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from dataclasses import dataclass, field
2+
from random import random
3+
4+
5+
@dataclass(slots=True)
6+
class FailurePolicy:
7+
"""
8+
Mapping from element tag to failure probability.
9+
"""
10+
11+
failure_probabilities: dict[str, float] = field(default_factory=dict)
12+
distribution: str = "uniform"
13+
14+
def test_failure(self, tag: str) -> bool:
15+
if self.distribution == "uniform":
16+
return random() < self.failure_probabilities.get(tag, 0)
17+
else:
18+
raise ValueError(f"Unsupported distribution: {self.distribution}")

ngraph/network.py

Lines changed: 81 additions & 186 deletions
Original file line numberDiff line numberDiff line change
@@ -1,207 +1,102 @@
11
from __future__ import annotations
22
import uuid
3-
from typing import Any, Dict, List, Optional, NamedTuple, Hashable
4-
import concurrent.futures
3+
import base64
4+
from dataclasses import dataclass, field
5+
from typing import Any, Dict
56

67

7-
from ngraph.lib.graph import MultiDiGraph
8-
from ngraph.lib.common import init_flow_graph
9-
from ngraph.lib.max_flow import calc_max_flow
8+
def new_base64_uuid() -> str:
9+
"""
10+
Generate a Base64-encoded UUID without padding (~22 characters).
11+
"""
12+
return base64.urlsafe_b64encode(uuid.uuid4().bytes).decode("ascii").rstrip("=")
1013

1114

12-
class LinkID(NamedTuple):
13-
src_node: Hashable
14-
dst_node: Hashable
15-
unique_id: Hashable
15+
@dataclass(slots=True)
16+
class Node:
17+
"""
18+
Represents a node in the network.
1619
20+
Each node is uniquely identified by its name, which is used as the key
21+
in the Network's node dictionary.
1722
18-
class Node:
19-
def __init__(self, node_id: str, node_type: str = "simple", **attributes: Dict):
20-
self.node_id: str = node_id
21-
self.node_type: str = node_type
22-
self.attributes: Dict[str, Any] = {
23-
"node_id": node_id,
24-
"node_type": node_type,
25-
"plane_ids": [],
26-
"total_link_capacity": 0,
27-
"non_transit": False,
28-
"transit_only": False, # no local sinks/sources
29-
"lat": 0,
30-
"lon": 0,
31-
}
32-
self.update_attributes(**attributes)
33-
self.sub_nodes: Dict[str, "Node"] = {} # Used if node_type is 'composite'
34-
self.sub_links: Dict[str, "Link"] = {} # Used if node_type is 'composite'
35-
36-
def add_sub_node(self, sub_node_id: str, **attributes: Any):
37-
# Logic to add a sub-node to a composite node
38-
...
39-
40-
def add_sub_link(
41-
self, sub_link_id: str, sub_node1: str, sub_node2: str, **attributes: Any
42-
):
43-
# Logic to add a sub-link to a composite node
44-
...
45-
46-
def update_attributes(self, **attributes: Any):
47-
"""
48-
Update the attributes of the node.
49-
"""
50-
self.attributes.update(attributes)
23+
:param name: The unique name of the node.
24+
:param attrs: Optional extra metadata for the node.
25+
"""
5126

27+
name: str
28+
attrs: Dict[str, Any] = field(default_factory=dict)
5229

30+
31+
@dataclass(slots=True)
5332
class Link:
54-
def __init__(
55-
self,
56-
node1: str,
57-
node2: str,
58-
link_id: Optional[LinkID] = None,
59-
**attributes: Dict,
60-
):
61-
self.link_id: str = (
62-
LinkID(node1, node2, str(uuid.uuid4())) if link_id is None else link_id
63-
)
64-
self.node1: str = node1
65-
self.node2: str = node2
66-
self.attributes: Dict[str, Any] = {
67-
"link_id": self.link_id,
68-
"node1": node1,
69-
"node2": node2,
70-
"plane_ids": [],
71-
"capacity": 0,
72-
"metric": 0,
73-
"distance": 0,
74-
}
75-
self.update_attributes(**attributes)
76-
77-
def update_attributes(self, **attributes: Any):
33+
"""
34+
Represents a link connecting two nodes in the network.
35+
36+
The 'source' and 'target' fields reference node names. A unique link ID
37+
is auto-generated from the source, target, and a random Base64-encoded UUID,
38+
allowing multiple distinct links between the same nodes.
39+
40+
:param source: Unique name of the source node.
41+
:param target: Unique name of the target node.
42+
:param capacity: Link capacity (default 1.0).
43+
:param latency: Link latency (default 1.0).
44+
:param cost: Link cost (default 1.0).
45+
:param attrs: Optional extra metadata for the link.
46+
:param id: Auto-generated unique link identifier.
47+
"""
48+
49+
source: str
50+
target: str
51+
capacity: float = 1.0
52+
latency: float = 1.0
53+
cost: float = 1.0
54+
attrs: Dict[str, Any] = field(default_factory=dict)
55+
id: str = field(init=False)
56+
57+
def __post_init__(self) -> None:
7858
"""
79-
Update the attributes of the link.
59+
Auto-generate a unique link ID by combining the source, target,
60+
and a random Base64-encoded UUID.
8061
"""
81-
self.attributes.update(attributes)
62+
self.id = f"{self.source}-{self.target}-{new_base64_uuid()}"
8263

8364

65+
@dataclass(slots=True)
8466
class Network:
85-
def __init__(self):
86-
self.planes: Dict[str, MultiDiGraph] = {} # Key is plane_id
87-
self.nodes: Dict[str, Node] = {} # Key is unique node_id
88-
self.links: Dict[str, Link] = {} # Key is unique link_id
67+
"""
68+
A container for network nodes and links.
8969
90-
@staticmethod
91-
def generate_edge_id(from_node: str, to_node: str, link_id: LinkID) -> str:
92-
"""
93-
Generate a unique edge ID for a link between two nodes.
94-
"""
95-
return LinkID(from_node, to_node, link_id[2])
96-
97-
def add_plane(self, plane_id: str):
98-
self.planes[plane_id] = init_flow_graph(MultiDiGraph())
99-
100-
def add_node(
101-
self,
102-
node_id: str,
103-
plane_ids: Optional[List[str]] = None,
104-
node_type: str = "simple",
105-
**attributes: Any,
106-
) -> str:
107-
new_node = Node(node_id, node_type, **attributes)
108-
self.nodes[new_node.node_id] = new_node
109-
110-
if plane_ids is None:
111-
plane_ids = self.planes.keys()
112-
113-
for plane_id in plane_ids:
114-
self.planes[plane_id].add_node(new_node.node_id, **attributes)
115-
new_node.attributes["plane_ids"].append(plane_id)
116-
return new_node.node_id
117-
118-
def add_link(
119-
self,
120-
node1: str,
121-
node2: str,
122-
plane_ids: Optional[List[str]] = None,
123-
**attributes: Any,
124-
) -> str:
125-
new_link = Link(node1, node2, **attributes)
126-
self.links[new_link.link_id] = new_link
127-
128-
if plane_ids is None:
129-
plane_ids = self.planes.keys()
130-
131-
for plane_id in plane_ids:
132-
self.planes[plane_id].add_edge(
133-
node1,
134-
node2,
135-
edge_id=self.generate_edge_id(node1, node2, new_link.link_id),
136-
capacity=new_link.attributes["capacity"] / len(plane_ids),
137-
metric=new_link.attributes["metric"],
138-
)
139-
self.planes[plane_id].add_edge(
140-
node2,
141-
node1,
142-
edge_id=self.generate_edge_id(node2, node1, new_link.link_id),
143-
capacity=new_link.attributes["capacity"] / len(plane_ids),
144-
metric=new_link.attributes["metric"],
145-
)
146-
new_link.attributes["plane_ids"].append(plane_id)
147-
148-
# Update the total link capacity of the nodes
149-
self.nodes[node1].attributes["total_link_capacity"] += new_link.attributes[
150-
"capacity"
151-
]
152-
self.nodes[node2].attributes["total_link_capacity"] += new_link.attributes[
153-
"capacity"
154-
]
155-
return new_link.link_id
156-
157-
@staticmethod
158-
def plane_max_flow(plane_id, plane_graph, src_node, dst_nodes) -> Optional[float]:
70+
Nodes are stored in a dictionary keyed by their unique names.
71+
Links are stored in a dictionary keyed by their auto-generated IDs.
72+
The 'attrs' dict allows extra network metadata.
73+
74+
:param nodes: Mapping from node name to Node.
75+
:param links: Mapping from link id to Link.
76+
:param attrs: Optional extra metadata for the network.
77+
"""
78+
79+
nodes: Dict[str, Node] = field(default_factory=dict)
80+
links: Dict[str, Link] = field(default_factory=dict)
81+
attrs: Dict[str, Any] = field(default_factory=dict)
82+
83+
def add_node(self, node: Node) -> None:
15984
"""
160-
Calculate the maximum flow between src and dst for a single plane.
161-
There can be multiple dst nodes, they all are attached to the same virtual sink node.
85+
Add a node to the network, keyed by its name.
86+
87+
:param node: The Node to add.
16288
"""
163-
if src_node in plane_graph:
164-
for dst_node in dst_nodes:
165-
if dst_node in plane_graph:
166-
# add a pseudo node to the graph to act as the sink for the max flow calculation
167-
plane_graph.add_edge(
168-
dst_node,
169-
"sink",
170-
edge_id=-1,
171-
capacity=2**32,
172-
metric=0,
173-
flow=0,
174-
flows={},
175-
)
176-
if "sink" in plane_graph:
177-
return calc_max_flow(plane_graph, src_node, "sink")
178-
179-
def calc_max_flow(
180-
self, src_nodes: List[str], dst_nodes: List[str]
181-
) -> Dict[str, Dict[str, float]]:
89+
self.nodes[node.name] = node
90+
91+
def add_link(self, link: Link) -> None:
18292
"""
183-
Calculate the maximum flow between each of the src nodes and all of the dst nodes.
184-
All the dst nodes are attached to the same virtual sink node.
185-
Runs the calculation in parallel for all planes and src nodes.
93+
Add a link to the network. Both source and target nodes must exist.
94+
95+
:param link: The Link to add.
96+
:raises ValueError: If the source or target node is not present.
18697
"""
187-
with concurrent.futures.ProcessPoolExecutor() as executor:
188-
future_to_plane_source = {}
189-
for plane_id in self.planes:
190-
for src_node in src_nodes:
191-
future_to_plane_source[
192-
executor.submit(
193-
self.plane_max_flow,
194-
plane_id,
195-
self.planes[plane_id],
196-
src_node,
197-
dst_nodes,
198-
)
199-
] = (plane_id, src_node, dst_nodes)
200-
201-
results = {}
202-
for future in concurrent.futures.as_completed(future_to_plane_source):
203-
plane_id, src_node, dst_nodes = future_to_plane_source[future]
204-
results.setdefault(src_node, {})
205-
results[src_node].setdefault(tuple(dst_nodes), {})
206-
results[src_node][tuple(dst_nodes)][plane_id] = future.result()
207-
return results
98+
if link.source not in self.nodes:
99+
raise ValueError(f"Source node '{link.source}' not found in network.")
100+
if link.target not in self.nodes:
101+
raise ValueError(f"Target node '{link.target}' not found in network.")
102+
self.links[link.id] = link

ngraph/results.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from dataclasses import dataclass, field
2+
from typing import Any, Dict
3+
4+
5+
@dataclass(slots=True)
6+
class Results:
7+
"""
8+
A container for storing arbitrary key-value data that arises during workflow steps.
9+
The data is organized by step name, then by key.
10+
11+
Example usage:
12+
results.put("Step1", "total_capacity", 123.45)
13+
cap = results.get("Step1", "total_capacity") # returns 123.45
14+
all_caps = results.get_all("total_capacity") # might return {"Step1": 123.45, "Step2": 98.76}
15+
"""
16+
17+
# Internally, store per-step data in a nested dict:
18+
# _store[step_name][key] = value
19+
_store: Dict[str, Dict[str, Any]] = field(default_factory=dict)
20+
21+
def put(self, step_name: str, key: str, value: Any) -> None:
22+
"""
23+
Store a value under (step_name, key).
24+
If the step_name sub-dict does not exist, it is created.
25+
26+
:param step_name: The workflow step that produced the result.
27+
:param key: A short label describing the data (e.g. "total_capacity").
28+
:param value: The actual data to store (can be any Python object).
29+
"""
30+
if step_name not in self._store:
31+
self._store[step_name] = {}
32+
self._store[step_name][key] = value
33+
34+
def get(self, step_name: str, key: str, default: Any = None) -> Any:
35+
"""
36+
Retrieve the value from (step_name, key). If the key is missing, return `default`.
37+
38+
:param step_name: The workflow step name.
39+
:param key: The key under which the data was stored.
40+
:param default: Value to return if the (step_name, key) is not present.
41+
:return: The data, or `default` if not found.
42+
"""
43+
return self._store.get(step_name, {}).get(key, default)
44+
45+
def get_all(self, key: str) -> Dict[str, Any]:
46+
"""
47+
Retrieve a dictionary of {step_name: value} for all step_names that contain the specified key.
48+
49+
:param key: The key to look up in each step.
50+
:return: A dict mapping step_name -> value for all steps that have stored something under 'key'.
51+
"""
52+
result = {}
53+
for step_name, data in self._store.items():
54+
if key in data:
55+
result[step_name] = data[key]
56+
return result

0 commit comments

Comments
 (0)