mirror of
https://github.com/Telecominfraproject/oopt-gnpy.git
synced 2025-11-02 11:07:57 +00:00
315 lines
12 KiB
Python
315 lines
12 KiB
Python
from networkx import DiGraph
|
||
from networkx.algorithms import all_simple_paths
|
||
from collections import namedtuple
|
||
from scipy.spatial.distance import cdist
|
||
from numpy import array
|
||
from itertools import product, islice, tee, count
|
||
from networkx import (draw_networkx_nodes,
|
||
draw_networkx_edges,
|
||
draw_networkx_labels,
|
||
draw_networkx_edge_labels,
|
||
spring_layout)
|
||
from matplotlib.pyplot import show, figure
|
||
from warnings import catch_warnings, simplefilter
|
||
from argparse import ArgumentParser
|
||
|
||
from logging import getLogger
|
||
logger = getLogger(__name__)
|
||
|
||
# remember me?
|
||
nwise = lambda g, n=2: zip(*(islice(g, i, None)
|
||
for i, g in enumerate(tee(g, n))))
|
||
|
||
# here's an example that includes a little
|
||
# bit of complexity to help suss out details
|
||
# of the proposed architecture
|
||
|
||
# let's pretend there's a working group whose
|
||
# job is to minimise latency of a network
|
||
# that working group gets the namespace LATENCY
|
||
|
||
# they interact with a working group whose
|
||
# job is to capture physical details of a network
|
||
# such as the geographic location of each node
|
||
# and whether nodes are mobile or fixed
|
||
# that working group gets the namespace PHYSICAL
|
||
|
||
# each working group can put any arbitrary Python
|
||
# object as the data for their given namespace
|
||
|
||
# the PHYSICAL group captures the following details
|
||
# of a NODE: - whether it is mobile or fixed
|
||
# - its (x, y) position
|
||
# of an EDGE: - the physical length of this connection
|
||
# - the speed of transmission over this link
|
||
|
||
# NOTE: for this example, we will consider network
|
||
# objects to be MUTABLE just to simplify
|
||
# the code
|
||
# if the graph object is immutable, then
|
||
# computations/transformations would return copies
|
||
# of the original graph. This can be done easily via
|
||
# `G.copy()`, but you'll have to account for the
|
||
# semantic difference between shallow-vs-deep copy
|
||
|
||
# NOTE: we'll put the Node & Edge information for these
|
||
# two working groups inside classes just for the
|
||
# purpose of namespacing & just so that we can
|
||
# write all the code for this example
|
||
# in a single .py file: normally these pieces
|
||
# would be in separate modules so that you can
|
||
# `from tpe.physical import Node, Edge`
|
||
|
||
|
||
class Physical:
|
||
# for Node: neither fixed nor position are computed fields
|
||
# - fixed cannot be changed (immutable)
|
||
# - position can be changed (mutable)
|
||
class Node:
|
||
def __init__(self, fixed, position):
|
||
self._fixed = fixed
|
||
self.position = position
|
||
|
||
@property
|
||
def fixed(self):
|
||
return self._fixed
|
||
|
||
@property
|
||
def position(self):
|
||
return self._position
|
||
|
||
@position.setter
|
||
def position(self, value):
|
||
if len(value) != 2:
|
||
raise ValueError('position must be (x, y) value')
|
||
self._position = value
|
||
|
||
def __repr__(self):
|
||
return f'Node({self.fixed}, {self.position})'
|
||
|
||
# for Edge:
|
||
# - speed (m/s) cannot be changed (immutable)
|
||
# - distance is COMPUTED
|
||
class Edge(namedtuple('Edge', 'speed endpoints')):
|
||
def distance(self, graph):
|
||
from_node, to_node = self.endpoints
|
||
positions = [graph.node[from_node]['physical'].position], \
|
||
[graph.node[to_node]['physical'].position]
|
||
return cdist(*positions)[0][0]
|
||
|
||
# NOTE: in this above, the computed edge data
|
||
# is computed on a per-edge basis
|
||
# which forces loops into Python
|
||
# however, the PHYSICAL working group has the
|
||
# power to decide what their API looks like
|
||
# and they could just as easily have provided
|
||
# some top-level function as part of their API
|
||
# to compute this "update" graph-wise
|
||
@staticmethod
|
||
def compute_distances(graph):
|
||
# XXX: here you can see the general clumsiness of moving
|
||
# in and out of the `numpy`/`scipy` computation "domain"
|
||
# which exposes another potential flaw in our model
|
||
# our model is very "naturalistic": we have Python objects
|
||
# that match to real-world objects like routers (nodes)
|
||
# and links (edges)
|
||
# however, from a computational perspective, we may find
|
||
# it more efficient to work within a mathematical
|
||
# domain where are objects are simply (sparse) matrices of
|
||
# graph data
|
||
# moving between these "naturalistic" and "mathematical"
|
||
# domains can be very clumsy
|
||
# note that there's also clumsiness in that the "naturalistic"
|
||
# modelling pushes data storage onto individual Python objects
|
||
# such as the edge data dicts whereas the mathematical
|
||
# modelling keeps the data in a single place (probably in
|
||
# the graph data dict); moving data between the two is also clumsy
|
||
data = {k: v['physical'].position for k, v in graph.node.items()}
|
||
positions = array(list(data.values()))
|
||
distances = cdist(positions, positions)
|
||
|
||
# we can either store the above information back onto the graph itself:
|
||
## graph['physical'].distances = distances
|
||
|
||
# or back onto the edge data itself:
|
||
# for (i, u), (j, v) in product(enumerate(data), enumerate(data)):
|
||
# if (u, v) not in graph.edge:
|
||
# continue
|
||
## edge, redge = graph.edge[u][v], graph.edge[v][u]
|
||
## dist = distances[i, j]
|
||
## edge['physical'].computed_distance = dist
|
||
|
||
# as part of the latency group's API, they specify that:
|
||
# - they consume PHYSICAL data
|
||
# - they modify PHYSICAl data
|
||
# - they do not add their own data
|
||
|
||
|
||
class Latency:
|
||
@staticmethod
|
||
def latency(graph, u, v):
|
||
paths = list(all_simple_paths(graph, u, v))
|
||
data = [(graph.get_edge_data(a, b)['physical'].speed,
|
||
graph.get_edge_data(a, b)['physical'].distance(graph))
|
||
for path in paths
|
||
for a, b in nwise(path)]
|
||
return min(distance / speed for speed, distance in data)
|
||
|
||
@staticmethod
|
||
def total_latency(graph):
|
||
return sum(Latency.latency(graph, u, v) for u, v in graph.edges())
|
||
|
||
@staticmethod
|
||
def nudge(u, v, precision=4):
|
||
(ux, uy), (vx, vy) = u, v
|
||
return (round(ux + (vx - ux) / 2, precision),
|
||
round(uy + (vy - uy) / 2, precision),)
|
||
|
||
@staticmethod
|
||
def gradient(graph, nodes):
|
||
# independently move each mobile node in the direction of one
|
||
# of its neighbors and compute the change in total_latency
|
||
for u in nodes:
|
||
for v in nodes[u]:
|
||
upos, vpos = graph.node[u]['physical'].position, \
|
||
graph.node[v]['physical'].position
|
||
new_upos = Latency.nudge(upos, vpos)
|
||
before = Latency.total_latency(graph)
|
||
graph.node[u]['physical'].position = new_upos
|
||
after = Latency.total_latency(graph)
|
||
graph.node[u]['physical'].position = upos
|
||
logger.info(
|
||
f'Gradient {u} ⇋ {v}; u to {new_upos}; grad {after-before}')
|
||
yield u, v, new_upos, after - before
|
||
|
||
# their public API may include only the following
|
||
# function for minimizing latency over a network
|
||
@staticmethod
|
||
def minimize(graph, *, n=5, threshold=1e-5 * 1e-9, d=None):
|
||
mobile = {k: list(graph.edge[k]) for k, v in graph.node.items()
|
||
if not v['physical'].fixed}
|
||
# XXX: VERY sloppy optimization repeatedly compute gradients
|
||
# nudging nodes in the direction of the best latency improvement
|
||
for it in count():
|
||
gradients = u, v, pos, grad = min(Latency.gradient(graph, mobile),
|
||
key=lambda rv: rv[-1])
|
||
logger.info(f'Best gradient {u} ⇋ {v}; u to {pos}; grad {grad}')
|
||
logger.info(
|
||
f'Moving {u} in dir of {v} for {grad/1e-12:.2f} ps gain')
|
||
graph.node[u]['physical'].position = pos
|
||
if d:
|
||
d.send((f'step #{it}', graph))
|
||
if it > n or abs(grad) < threshold: # stop after N iterations
|
||
break # or if improvement < threshold
|
||
|
||
# our Network object is just a networkx.DiGraph
|
||
# with some additional storage for graph-level
|
||
# data
|
||
# NOTE: this may actually need to be a networkx.MultiDiGraph?
|
||
# in the event that graphs may have multiple links
|
||
# with distance edge data connecting them
|
||
|
||
|
||
def Network(*args, data=None, **kwargs):
|
||
n = DiGraph()
|
||
n.data = {} if data is None else data
|
||
return n
|
||
|
||
|
||
def draw_changes():
|
||
''' simple helper to draw changes to the network '''
|
||
fig = figure()
|
||
for n in count():
|
||
data = yield
|
||
if not data:
|
||
break
|
||
for i, ax in enumerate(fig.axes):
|
||
ax.change_geometry(n + 1, 1, i + 1)
|
||
ax = fig.add_subplot(n + 1, 1, n + 1)
|
||
title, network, *edge_labels = data
|
||
node_data = {u: (u, network.node[u]['physical'].position)
|
||
for u in network.nodes()}
|
||
edge_data = {(u, v): (network.get_edge_data(u, v)['physical'].distance(network),
|
||
network.get_edge_data(u, v)['physical'].speed,)
|
||
for u, v in network.edges()}
|
||
labels = {u: f'{n}' for u, (n, p) in node_data.items()}
|
||
distances = {(u, v): f'dist = {d:.2f} m\nspeed = {s/1e6:.2f}e6 m/s'
|
||
for (u, v), (d, s) in edge_data.items()}
|
||
|
||
pos = {u: p for u, (_, p) in node_data.items()}
|
||
label_pos = pos
|
||
|
||
draw_networkx_edges(network, alpha=.25, width=.5, pos=pos, ax=ax)
|
||
draw_networkx_nodes(network, node_size=600, alpha=.5, pos=pos, ax=ax)
|
||
draw_networkx_labels(network, labels=labels,
|
||
pos=pos, label_pos=.3, ax=ax)
|
||
if edge_labels:
|
||
draw_networkx_edge_labels(
|
||
network, edge_labels=distances, pos=pos, font_size=8, ax=ax)
|
||
|
||
ax.set_title(title)
|
||
ax.set_axis_off()
|
||
|
||
with catch_warnings():
|
||
simplefilter('ignore')
|
||
show()
|
||
yield
|
||
|
||
|
||
parser = ArgumentParser()
|
||
parser.add_argument('-v', action='count')
|
||
|
||
if __name__ == '__main__':
|
||
from logging import basicConfig, INFO
|
||
args = parser.parse_args()
|
||
if args.v:
|
||
basicConfig(level=INFO)
|
||
|
||
print('''
|
||
Sample network has nodes:
|
||
a ⇋ b ⇋ c ⇋ d
|
||
|
||
signals a ⇋ b travel at speed of light through copper
|
||
signals b ⇋ c travel at speed of light through water
|
||
signals c ⇋ d travel at speed of light through water
|
||
|
||
all connections are bidirectional
|
||
|
||
a, c, d are fixed position
|
||
b is mobile
|
||
|
||
How can we move b to maximise speed of transmission a ⇋ d?
|
||
''')
|
||
|
||
# create network
|
||
n = Network()
|
||
for name, fixed, (x, y) in [('a', True, (0, 0)),
|
||
('b', False, (5, 5)),
|
||
('c', True, (10, 10)),
|
||
('d', True, (20, 20)), ]:
|
||
n.add_node(name, physical=Physical.Node(fixed=fixed, position=(x, y)))
|
||
for u, v, speed in [('a', 'b', 299790000),
|
||
('b', 'c', 225000000),
|
||
('c', 'd', 225000000), ]:
|
||
n.add_edge(u, v, physical=Physical.Edge(speed=speed, endpoints=(u, v)))
|
||
n.add_edge(v, u, physical=Physical.Edge(speed=speed, endpoints=(v, u)))
|
||
|
||
d = draw_changes()
|
||
next(d)
|
||
d.send(('initial', n, True))
|
||
|
||
# core computation
|
||
latency = Latency.latency(n, 'a', 'd')
|
||
total_latency = Latency.total_latency(n)
|
||
Latency.minimize(n, d=d)
|
||
total_latency = Latency.total_latency(n)
|
||
|
||
print('Before:')
|
||
print(f' Current latency from a ⇋ d: {latency/1e-9:.2f} ns')
|
||
print(f' Total latency on n: {total_latency/1e-9:.2f} ns')
|
||
|
||
print('After:')
|
||
print(f' Total latency on n: {total_latency/1e-9:.2f} ns')
|
||
|
||
next(d)
|