# Copyright (c) Max-Planck-Gesellschaft zur Förderung der Wissenschaften e.V.
# Developed at the Max Planck Institute for Informatics, Network and Cloud Systems Group
#
# Author: Yiming Lei (ylei@mpi-inf.mpg.de)
#
# This software is licensed for non-commercial scientific research purposes only.
#
# License text: Creative Commons NC BY SA 4.0
# https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en
import os
import time
import warnings
import networkx as nx
import openoptics.utils as utils
from openoptics.backends import create_backend
from openoptics.dashboard import NullDashboard
from openoptics.DeviceManager import DeviceManager
from openoptics.OpticalCLI import OpticalCLI
from openoptics.TimeFlowTable import Path, TimeFlowEntry
from typing import List, Union
[docs]
class BaseNetwork:
"""
The base class of optical networks.
This class provides the foundation for creating and managing optical networks
in OpenOptics. It includes topology and routing configurations,
network monitoring, and interaction with the underlying backend.
The class handles both Traffic-Oblivious (TO) and Traffic-Aware (TA) architectures,
allowing for pre-defined topology or runtime reconfiguration.
"""
[docs]
def __init__(
self,
name,
nb_node,
nb_link=1,
nb_host_per_tor=1,
backend="Mininet",
time_slice_duration_us=None,
time_slice_duration_ms=None,
guardband_us=None,
guardband_ms=None,
arch_mode="TO", # TO for traffic-oblivious, TA for traffic-aware
use_webserver=True,
ocs_tor_link_bw_gbps: float = 1.0,
tor_host_link_bw_gbps: float = 1.0,
**backend_kwargs,
):
"""
Initialize the BaseNetwork.
Args:
name (str): Name of the network
backend (str): Backend of OpenOptics. "Mininet", "Testbed", or "ns3"
nb_node (int): Number of nodes in the network
nb_link (int, optional): Number of links per node (defaults to 1)
nb_host_per_tor (int, optional): Number of hosts per ToR (defaults to 1)
time_slice_duration_us (int, optional): Duration of each time slice in microseconds
time_slice_duration_ms (int, optional): Duration of each time slice in milliseconds
Specify at most one of time_slice_duration_us or time_slice_duration_ms (default 128 ms).
guardband_us (int, optional): Guard band in microseconds
guardband_ms (int, optional): Guard band in milliseconds
Specify at most one of guardband_us or guardband_ms (default 25 ms).
arch_mode (str, optional): architecture mode: "TO" or "TA" (defaults to "TO")
use_webserver (bool, optional): Whether to use web server for dashboard (defaults to True)
ocs_tor_link_bw_gbps (float, optional): Bandwidth in Gbps for OCS↔ToR links (defaults to 1.0)
tor_host_link_bw_gbps (float, optional): Bandwidth in Gbps for ToR↔host links (defaults to 1.0)
**backend_kwargs: Backend-specific parameters (e.g. ``link_delay_ms`` for
Mininet/ns-3). Validated immediately against the selected backend's
``accepted_kwargs()``; unknown names raise ``ValueError``.
"""
self.host_tor_port = 0
assert nb_link > 0
self.tor_host_port = nb_link
self.tor_ocs_ports = list(range(nb_link))
self.name = name
self.nb_time_slices = 1
# Time slice and guardband: accept either µs or ms, store canonical µs
# only. Backends receive the µs form via setup() and convert if their
# underlying runtime is ms-only (e.g. Mininet's BMv2 CLI).
if time_slice_duration_us is not None and time_slice_duration_ms is not None:
raise ValueError("Specify only one of time_slice_duration_us or time_slice_duration_ms.")
if time_slice_duration_us is not None:
self.time_slice_duration_us = int(time_slice_duration_us)
elif time_slice_duration_ms is not None:
self.time_slice_duration_us = int(time_slice_duration_ms * 1000)
else:
self.time_slice_duration_us = 128_000 # default: 128 ms
if guardband_us is not None and guardband_ms is not None:
raise ValueError("Specify only one of guardband_us or guardband_ms.")
if guardband_us is not None:
self.guardband_us = int(guardband_us)
elif guardband_ms is not None:
self.guardband_us = int(guardband_ms * 1000)
else:
self.guardband_us = 25_000 # default: 25 ms
self.arch_mode = arch_mode
self.calendar_queue_mode = 0 if arch_mode == "TO" else 1
self.slice_to_topo = {}
self.nb_node = nb_node
self.nb_link = nb_link
self.nb_host_per_tor = nb_host_per_tor
self.nodes_created = False
self.use_webserver = use_webserver
self.ocs_tor_link_bw_gbps = float(ocs_tor_link_bw_gbps)
self.tor_host_link_bw_gbps = float(tor_host_link_bw_gbps)
# Always present so callers can invoke dashboard methods unconditionally;
# start_monitor() replaces this with a real DashboardService when
# use_webserver=True.
self.dashboard = NullDashboard()
self._backend = create_backend(backend)
accepted = type(self._backend).accepted_kwargs()
unknown = set(backend_kwargs) - accepted
if unknown:
raise ValueError(
f"Backend '{backend}' does not accept: {unknown}. "
f"Accepted backend-specific kwargs: {accepted or 'none'}"
)
self._backend_kwargs = backend_kwargs
print("Setting up OpenOptics...")
def __str__(self) -> str:
"""
Return string representation of the network.
Returns:
str: Name of the network
"""
return self.name
def start_monitor(self):
"""
Start OpenOptics DeviceManager and Dashboard.
Initialises the monitoring system and starts the web dashboard when
``use_webserver`` is enabled and the selected backend can feed it
metrics. The dashboard runs in-process on the host and port configured
by :class:`DashboardConfig` (default ``localhost:8001``).
"""
dashboard_enabled = self.use_webserver and (
self._backend.supports_device_manager
or getattr(self._backend, "supports_dashboard_without_device_manager", False)
)
if dashboard_enabled:
from openoptics.dashboard.collectors import ReconfigEventPublisher
reconfig_publisher = ReconfigEventPublisher()
else:
reconfig_publisher = None
if self._backend.supports_device_manager:
self.device_manager = DeviceManager(
self._backend,
self.tor_ocs_ports,
nb_queue=self.nb_time_slices if self.arch_mode == "TO" else self.nb_node,
event_publisher=reconfig_publisher,
)
else:
self.device_manager = None
if dashboard_enabled:
from openoptics.dashboard import DashboardConfig, DashboardService
self.dashboard = DashboardService(DashboardConfig.from_env())
self.dashboard.begin_epoch()
self.dashboard.update_topology(self.slice_to_topo)
# Mininet-style polling collector (needs a live DeviceManager).
if self.device_manager is not None:
from openoptics.dashboard.collectors import DeviceMetricCollector
self.dashboard.register_collector(DeviceMetricCollector(
self.device_manager,
nb_port=self.nb_link,
nb_queue=(
self.nb_time_slices
if self.calendar_queue_mode == 0
else self.nb_node
),
interval_s=self.dashboard.config.poll_interval_s,
))
# Backend-specific wiring: simulator backends register their own
# event-source sinks and connect ns-3 TraceSources. Default is a
# no-op; Ns3Backend overrides.
self._backend.setup_dashboard(self.dashboard)
self.dashboard.register_event_source(reconfig_publisher)
self.dashboard.start()
def start_cli(self):
"""
Start OpenOptics CLI.
Launches the command-line interface for interacting with the network.
"""
OpticalCLI(self)
def stop_network(self):
"""
Stop the network.
Stops the dashboard (if running) and the backend network.
"""
self.dashboard.stop()
self._backend.stop()
def create_nodes(self):
"""Create nodes via the configured backend."""
self._backend.setup(
nb_node=self.nb_node,
nb_host_per_tor=self.nb_host_per_tor,
nb_link=self.nb_link,
nb_time_slices=self.nb_time_slices,
time_slice_duration_us=self.time_slice_duration_us,
guardband_us=self.guardband_us,
calendar_queue_mode=self.calendar_queue_mode,
ocs_tor_link_bw_gbps=self.ocs_tor_link_bw_gbps,
tor_host_link_bw_gbps=self.tor_host_link_bw_gbps,
**self._backend_kwargs,
)
def udp_traffic(self, **defaults):
"""Return a UDP traffic builder when the selected backend supports it."""
traffic_builder = getattr(self._backend, "udp_traffic", None)
if not callable(traffic_builder):
raise NotImplementedError(
"udp_traffic() is currently supported only by the ns-3 backend"
)
return traffic_builder(**defaults)
def tcp_traffic(self, **defaults):
"""Return a TCP traffic builder when the selected backend supports it."""
traffic_builder = getattr(self._backend, "tcp_traffic", None)
if not callable(traffic_builder):
raise NotImplementedError(
"tcp_traffic() is currently supported only by the ns-3 backend"
)
return traffic_builder(**defaults)
def cal_node_port_to_ocs_port(self, node_id, port_id):
"""
Find the OCS's port that connects to a node's port.
Args:
node_id (int): ID of the ToR
port_id (int): ID of the ToR's port
Returns:
int: the OCS's port
"""
return port_id * self.nb_node + node_id
def setup_ocs(self):
"""
Generate commands for OCS forwarding.
Creates and loads the OCS forwarding table entries based on
the current topology configuration.
"""
ocs_slice_port1_port2 = []
for ts, graph in self.slice_to_topo.items():
for node1, node2, attr in nx.to_edgelist(graph):
port1, port2 = attr["port1"], attr["port2"]
ocs_port1 = self.cal_node_port_to_ocs_port(node1, port1)
ocs_port2 = self.cal_node_port_to_ocs_port(node2, port2)
ocs_slice_port1_port2.append((ts, ocs_port1, ocs_port2))
ocs_entries = utils.gen_ocs_commands(ocs_slice_port1_port2)
self._backend.load_table(
switch_name="ocs",
entries=ocs_entries,
print_flag=False,
)
def setup_nodes(self):
"""
Load utility tables into nodes.
Configures the ToR switches with necessary routing and forwarding tables
including IP to destination mappings, arrival verification, and port calculation.
"""
print("Setting up switch tables...")
ip_to_tor = self._backend.get_ip_to_tor()
ip_to_dst_entries = utils.tor_table_ip_to_dst(ip_to_tor)
for tor_id in range(self.nb_node):
arrive_at_dst = utils.tor_table_arrive_at_dst(tor_id, self.tor_host_port)
verify_desired_node = utils.tor_table_verify_desired_node(tor_id)
cal_port_enqueue = utils.tor_table_cal_port_slice_to_node(
tor_id, self.slice_to_topo
)
self._backend.load_table(
switch_name=f"tor{tor_id}",
entries=ip_to_dst_entries
+ arrive_at_dst
+ verify_desired_node
+ cal_port_enqueue,
print_flag=False,
save_flag=False,
)
[docs]
def start(self):
"""
Start OpenOptics user interface (CLI, dashboard, ...).
Traffic Oblivious. Initializes DeviceManager, starts CLI, and handles
network shutdown.
Simulator-style backends (``supports_cli == False``) skip the
interactive CLI and drive the backend's own ``run()`` instead; this
path is taken by the ns-3 backend.
"""
self.start_monitor()
if getattr(self._backend, "supports_cli", True):
self.start_cli()
else:
self._backend.run()
self.stop_network()
[docs]
def start_traffic_aware(
self, topo_func=None, routing_func=None, routing_mode=None, update_interval=1
) -> bool:
"""Deploy traffic aware architecture.
Args:
topo_func: traffic aware topology function with traffic matrix as input
routing_func: routing function used by traffic aware architecture
routing_mode: Source or Per-hop
update_interval: interval in seconds to update topology and routing
Return:
Whether the traffic aware architecture is successfully deployed.
"""
if self.nb_link != 1:
raise ValueError("Traffic aware architecture only supports one link.")
import threading
stop_event = threading.Event()
self.start_monitor()
self.activate_calendar_queue()
def evolve():
prev_circuits = []
while not stop_event.is_set():
metric = self.device_manager.get_device_metric()
traffic_matrix = utils.metric_to_matrix(metric)
if topo_func:
circuits = topo_func(
nb_node=self.nb_node,
nb_link=self.nb_link,
traffic_matrix=traffic_matrix,
prev_circuits=prev_circuits,
)
if prev_circuits != circuits:
prev_circuits = circuits
self.pause_calendar_queue()
assert self.deploy_topo(circuits, start_fresh=True)
self.activate_calendar_queue()
stop_event.wait(timeout=update_interval)
evolve_thread = threading.Thread(target=evolve)
evolve_thread.start()
self.start_cli()
stop_event.set()
evolve_thread.join()
self.stop_network()
##########################
# Optical Topology #
##########################
[docs]
def connect(
self, time_slice, node1, node2, port1=0, port2=0, unidirectional=False
) -> bool:
"""
Connect two node ports at the given time slice by configuring OCS,
if no optical connections haven't been built for both ports.
Args:
time_slice (int): The time slice to connect
node1 (int): The first of two nodes to connect
node2 (int): The second of two nodes to connect
port1 (int, optional): The port the first node uses to connect. Defaults to 0.
port2 (int, optional): The port the second node uses to connect. Defaults to 0.
unidirectional (bool, optional): Whether connection is unidirectional. Defaults to False.
Returns:
bool: Whether the connect() was successful.
"""
if not isinstance(time_slice, int) or time_slice < 0:
raise ValueError(f"Invalid time slice {time_slice}.")
if not isinstance(node1, int) or node1 < 0 or node1 >= self.nb_node:
raise ValueError(
f"Invalid node {node1}. Only nodes 0 to {self.nb_node - 1} are valid. Are you setting the correct nb_node when generating topology?"
)
if not isinstance(node2, int) or node2 < 0 or node2 >= self.nb_node:
raise ValueError(
f"Invalid node {node2}. Only nodes 0 to {self.nb_node - 1} are valid. Are you setting the correct nb_node when generating topology?"
)
if time_slice not in self.slice_to_topo.keys():
for added_time_slice in range(time_slice + 1):
if added_time_slice not in self.slice_to_topo.keys():
self.slice_to_topo[added_time_slice] = nx.DiGraph()
self.slice_to_topo[added_time_slice].add_nodes_from(
range(self.nb_node)
)
nodes = self.slice_to_topo[time_slice].nodes
if ((port1 not in nodes[node1]) or (nodes[node1][port1] == False)) and (
(port2 not in nodes[node2]) or (nodes[node2][port2] == False)
):
nodes[node1][port1] = True
nodes[node2][port2] = True
self.slice_to_topo[time_slice].add_edge(node1, node2, port1=port1, port2=port2)
if unidirectional == False:
self.slice_to_topo[time_slice].add_edge(
node2, node1, port1=port2, port2=port1
)
return True
else:
print(
f"Port(s) occupied: Time slice {time_slice} can NOT connect node {node1} port {port1} to node {node2} port {port2} "
)
return False
[docs]
def disconnect(
self, time_slice, node1, node2, port1=0, port2=0, unidirectional=False
) -> bool:
"""
Disconnect two node ports at the given time slice.
Args:
node1 (int): First node
port1 (int): Port of first node
node2 (int): Second node
port2 (int): Port of second node
time_slice (int): Time slice to disconnect
unidirectional (bool, optional): Whether disconnection is unidirectional. Defaults to False.
Returns:
bool: Whether the disconnection was successful
"""
if time_slice not in self.slice_to_topo:
print(f"Time slice {time_slice} not found.")
return False
nodes = self.slice_to_topo[time_slice].nodes
if self.slice_to_topo[time_slice].has_edge(node1, node2) == False:
print(
f"Time slice {time_slice} does not have edge between node {node1} and node {node2}."
)
return False
if nodes[node1][port1] == False:
print(
f"Node {node1} Port {port1} is not the correct port connected to node {node2}."
)
return False
if nodes[node2][port2] == False:
print(
f"Node {node2} Port {port2} is not the correct port connected to node {node1}."
)
return False
nodes[node1][port1] = False
nodes[node2][port2] = False
self.slice_to_topo[time_slice].remove_edge(node1, node2)
if unidirectional == False:
self.slice_to_topo[time_slice].remove_edge(node2, node1)
return True
[docs]
def deploy_topo(self, circuits=[], start_fresh=False) -> bool:
"""
Create ocs schedules based on given circuits or existing slice_to_topo variable (updated by connect() before),
and load them to OCS.
Create nodes in the backend if it is the first time deploy_topo is called.
Args:
circuits (list, optional): A list of tuples (time_slice, node1, node2, port1, port2). Defaults to [].
start_fresh (bool, optional): Whether to start with a fresh topology. Defaults to False.
Returns:
bool: Whether the given circuits are successfully deployed.
"""
if start_fresh:
self.slice_to_topo = {}
nb_time_slices_hint = 0
for time_slice, node1, node2, port1, port2 in circuits:
nb_time_slices_hint = max(nb_time_slices_hint, time_slice + 1)
if node1 == -1 or node2 == -1:
# Placeholder for an empty time slice (e.g. guardband with no circuits)
continue
if not self.connect(time_slice, node1, node2, port1, port2):
print("Topology deployment failed.")
return False
# Create empty DiGraphs for any time slices not yet populated
for ts in range(nb_time_slices_hint):
if ts not in self.slice_to_topo:
self.slice_to_topo[ts] = nx.DiGraph()
self.slice_to_topo[ts].add_nodes_from(range(self.nb_node))
self.nb_time_slices = len(self.slice_to_topo.keys())
if self.nb_time_slices == 0:
raise Exception("No time slices deployed.")
if self.nodes_created:
self.dashboard.update_topology(self.slice_to_topo)
if not self.nodes_created:
self.create_nodes()
self.nodes_created = True
print("Deploying optical topologies...")
self._backend.clear_table(
switch_name="ocs",
table="ocs_schedule",
)
self.setup_ocs()
if hasattr(self._backend, 'gen_schedule'):
self._backend.gen_schedule(self.slice_to_topo)
return True
[docs]
def get_topo(self, time_slice=None) -> nx.Graph:
"""
Get the topology (nx.Graph) at the given time slice.
Args:
time_slice (int): Time slice to retrieve topology for
Returns:
nx.Graph: The network topology at the given time slice, or None if not found
"""
if time_slice is None:
return self.slice_to_topo
if time_slice not in self.slice_to_topo.keys():
print("Time slice not found.")
return None
return self.slice_to_topo[time_slice]
[docs]
def pause_calendar_queue(self):
"""Pause traffic before reconfigure topology. Used in TA architecture.
Sets the active queue for each node to itself, as no pkts should be there,
this effectively pauses the calendar queues during topology reconfiguration.
"""
for node1, node2, attr in nx.to_edgelist(self.slice_to_topo[0]):
port1, port2 = attr["port1"], attr["port2"]
assert port1 == 0 and port2 == 0, (
"Now control calendar queue only supports one link per node"
)
self.device_manager.set_active_queue(f"tor{node1}", node1)
[docs]
def activate_calendar_queue(self):
"""Update active calendar queues based on the current topology, for traffic-aware.
Each calendar queue buffers packets to a destination node.
Pause calendar queues whose packets' dst is not directly connected.
"""
assert len(self.slice_to_topo.keys()) == 1, (
f"Control calendar queue is only supported for TA with one time slice. \
{len(self.slice_to_topo.keys())} time slices found."
)
for node1, node2, attr in nx.to_edgelist(self.slice_to_topo[0]):
port1, port2 = attr["port1"], attr["port2"]
assert port1 == 0 and port2 == 0, (
"Now control calendar queue only supports one link per node"
)
self.device_manager.set_active_queue(f"tor{node1}", node2)
##########################
# Routing #
##########################
[docs]
def add_time_flow_entry(
self, node_id, entries: Union[List[TimeFlowEntry], TimeFlowEntry], routing_mode="Per-hop"
) -> bool:
"""
Add the time flow entry(s) to the node.
Args:
node_id (int): The node to add entries to
entries (TimeFlowEntry or List[TimeFlowEntry]): A TimeFlowEntry or a list of TimeFlowEntry
routing_mode (str): Source or Per-hop
Returns:
bool: Whether the entries were successfully added.
"""
if isinstance(entries, TimeFlowEntry):
entries = [entries]
elif not isinstance(entries, list):
raise ValueError("entries must be a TimeFlowEntry or a list of TimeFlowEntry")
table_entries = []
if routing_mode == "Source":
for entry in entries:
table_entries += utils.tor_table_routing_source(entry, nb_time_slices=self.nb_time_slices)
elif routing_mode == "Per-hop":
for entry in entries:
table_entries += utils.tor_table_routing_per_hop(entry, nb_time_slices=self.nb_time_slices)
else:
assert False, "Unsupported routing mode"
if not self._backend.switch_exists(f"tor{node_id}"):
print(f"Error: Try deploying paths to non-existent node: node{node_id}.")
return False
return self._backend.load_table(f"tor{node_id}", table_entries)
[docs]
def deploy_routing(
self,
paths: List[Path],
routing_mode="Per-hop",
arch_mode="TO",
start_fresh=False,
) -> bool:
"""
Deploy routing to nodes.
Args:
paths (List[Path]): A list of paths to be deployed to the network nodes
routing_mode (str): The routing mode, either "Per-hop" or "Source"
arch_mode (str, optional): The architecture mode, either "TO" (Traffic-Oblivious) or "TA" (Traffic-Aware). Defaults to "TO".
start_fresh (bool, optional): If True, clears existing routing table entries before deploying new ones. Defaults to False.
Returns:
bool: True if routing deployment is successful
"""
# Load utility tables into ToR switches (ip_to_dst, arrive_at_dst, etc.)
self.setup_nodes()
if routing_mode == "Source":
cap = getattr(self._backend, "max_source_route_hops", None)
if cap is not None:
offending = [p for p in paths if len(p.steps) > cap]
if offending:
longest = max(len(p.steps) for p in offending)
sample = offending[0]
warnings.warn(
f"[deploy_routing] {len(offending)}/{len(paths)} "
f"source-routed paths exceed the "
f"{type(self._backend).__name__} SR cap of {cap} "
f"hops (longest={longest}, e.g. src={sample.src} "
f"dst={sample.dst} arrival_ts={sample.arrival_ts}). "
f"Bound the routing function "
f"(`routing_hoho(..., max_hop={cap})`) or switch to "
f"`routing_mode=\"Per-hop\"`.",
RuntimeWarning,
stacklevel=2,
)
if start_fresh:
print("Loading routings...")
table_name = (
"per_hop_routing" if routing_mode == "Per-hop" else "add_source_routing_entries"
)
for node_id in range(self.nb_node):
self._backend.clear_table(
switch_name=f"tor{node_id}",
table=table_name,
)
entry_dict = utils.path2entries(paths, routing_mode, arch_mode=arch_mode)
for src, entries in entry_dict.items():
self.add_time_flow_entry(src, entries, routing_mode=routing_mode)
return True