cocluto/PowerDiagram.py

447 lines
18 KiB
Python

'''
The goal of this application is to generate a power diagram that will help system administrators to:
- document the power supply architecture
- easily spot potential power overloads (for example if the power consumption exceeds the capacity of a cable)
This application takes its input from a database, currently in the form of an sql dump, but it could easily be adapted to read directly from a mysql database
'''
import re
import pygraphviz # port install py-pygraphviz
from inventory import Inventory, MachineSpecIdNotFound
from SimpaDbUtil import SqlFile, SqlDatabaseReader
from Lib import SimpaDbUtil
def add_capacity_constraints(capacity1, capacity2):
"""
combines 2 capacity constraints (max amperes) together
:param float capacity1: max amperes for the first capacity, None if there are no constraints
:param float capacity2: max amperes for the second capacity, None if there are no constraints
:return float: max amperes for the combined capacity, None if there are no constraints
"""
if capacity1 is None:
return capacity2
else:
if capacity2 is None:
return capacity1
else:
return min(capacity1, capacity2)
class Machine(object):
"""
represents a device with input and output power plugs. It could represent a power consumer such as a server (in which case, it has no output plug), or a power distrubtion unit (in which case, it has one input plug and more than one output plugs), or even something else...
"""
def __init__(self, name, power_config):
self.name = name
self.input_plugs = {}
self.output_plugs = {}
self.current_capacity_constraint = None # the maximum amperes in this connection
self.power_config = power_config
self.power_consumption = 0.0
self.rack_id = None
def get_plug(self, plug_name):
plugs = {'i': self.input_plugs, 'o': self.output_plugs}[plug_name[0]]
if plug_name not in plugs:
plugs[plug_name] = Plug(plug_name, self, self.power_config)
return plugs[plug_name]
def get_max_amperes(self):
capacity = None
if len(self.input_plugs) > 0:
capacity = self.input_plugs.values()[0].get_max_amperes()
capacity = add_capacity_constraints(capacity, self.current_capacity_constraint)
return capacity
def get_outgoing_connections(self):
outgoing_connections = []
for conn in self.power_config.connections:
if conn.from_plug.machine == self:
outgoing_connections.append(conn)
return outgoing_connections
def get_power_consumption(self):
power_consumption = self.power_consumption
for conn in self.get_outgoing_connections():
power_consumption += conn.get_power_consumption()
# print("machine %s : power_consumption += %f" % (self.name, conn.get_power_consumption()))
return power_consumption
def is_power_provider(self):
return self.name == 'edf' or re.match('^ups[0-9]*$', self.name)
class Plug(object):
"""
represents a power plug (input or output) of a device
"""
def __init__(self, name, machine, power_config):
self.name = name
self.machine = machine
self.current_capacity_constraint = None # the maximum amperes in this connection
self.power_config = power_config
def __str__(self):
return self.machine.name + '.' + self.name
def get_incoming_connection(self):
return self.power_config.get_connection_to(self)
# def get_outgoing_connections(self):
# return self.power_config.get_connections_from(self)
def is_input_plug(self):
return self.name[0] == 'i'
def set_current_capacity_constraint(self, max_amps):
self.current_capacity_constraint = max_amps
def get_max_amperes(self):
capacity = None
debug = False
if self.is_input_plug():
in_con = self.get_incoming_connection()
if in_con:
# apply incoming connection amperes limitation
capacity = add_capacity_constraints(capacity, in_con.get_max_amperes())
if debug:
print(str(self)+ 'after incoming connection amperes limitation, capacity = ' + str(capacity))
else:
# apply the machine containing this plug's amperes limitation
capacity = add_capacity_constraints(capacity, self.machine.get_max_amperes())
if debug:
print(str(self)+'apply the machine containing this plug s amperes limitation, capacity = ' + str(capacity))
# apply this plug's amperes limitation
capacity = add_capacity_constraints(capacity, self.current_capacity_constraint)
if debug:
print(str(self)+'after apply this plug s amperes limitation, capacity = ' + str(capacity), self.current_capacity_constraint)
return capacity
# def get_power_consumption(self):
# power_consumption = 0.0
# for conn in self.get_outgoing_connections():
# power_consumption += conn.get_power_consumption()
# power_consumption += self.get_power_consumption()
# return self.from_plug.get_power_consumption()
class Connection(object):
"""
a power cable connecting an input power plug to an output power plug
"""
def __init__(self, from_plug, to_plug):
self.from_plug = from_plug
self.to_plug = to_plug
self.current_capacity_constraint = None # the maximum amperes in this connection
def __str__(self):
return str(self.from_plug) + ' -> ' + str(self.to_plug) + ' (' + str(self.from_plug.get_max_amperes()) + ' A, ' + str(self.get_max_amperes()) + ' A)'
def get_max_amperes(self):
# gLogger.debug('%s (%s A) -> %s (%s A): ' % (str(self.from_plug), str(self.from_plug.get_max_amperes()), str(self.to_plug), str(self.to_plug.current_capacity_constraint)))
capacity = self.from_plug.get_max_amperes()
capacity = add_capacity_constraints(capacity, self.to_plug.current_capacity_constraint)
if self.current_capacity_constraint is not None:
capacity = min(capacity, self.current_capacity_constraint)
return capacity
def is_redundancy_cable(self):
to_machine = self.to_plug.machine
my_power_provider = self.get_power_provider()
# find the first sibling cable that has the same provider as self
first_cable_with_same_provider = None
for input_plug in to_machine.input_plugs.itervalues():
sibling_cable = input_plug.get_incoming_connection()
if sibling_cable.get_power_provider() == my_power_provider:
first_cable_with_same_provider = sibling_cable
if first_cable_with_same_provider is None:
# no other connection with the same provider
return False
if first_cable_with_same_provider == self:
# for each provider, the 1st cable amongst the connectors using this provider is considered to be the original (not redundant)
return False
else:
# for each provider, all cable but the 1st one are considered as redundant
return True
def get_power_provider(self):
from_machine = self.from_plug.machine
if from_machine.is_power_provider():
return from_machine
input_plug_names = from_machine.input_plugs.keys()
assert len(input_plug_names) == 1, "from_machine is supposed to be a power strip (which is expected to only have one input)"
input_plug = from_machine.input_plugs[input_plug_names[0]]
return input_plug.get_incoming_connection().get_power_provider()
def get_power_consumption(self):
# at the moment, this program doesn't handle redundant power supplies properly:
# the energy dragged by a power supply depends whether both power supplies are connnected to the same provider or not
if self.is_redundancy_cable():
return 0.0 # consider secondary power supplies to drag 0 power
else:
return self.to_plug.machine.get_power_consumption()
class PowerConfig(object):
"""
the description of how machines are connected together (in terms of electric power)
"""
def __init__(self, simpa_db_sql_file_path):
self.machines = {}
self.connections = []
sql_source = SqlFile(simpa_db_sql_file_path)
sql_reader = SqlDatabaseReader(sql_source)
inventory = Inventory(sql_reader)
self._parse_from_inventory(inventory)
def _parse_from_inventory(self, inventory):
"""
:param Inventory inventory:
"""
rows = inventory.query("SELECT * FROM machine_to_power")
for row in rows:
(to_plug_as_str, from_plug_as_str, powercordid) = row
if to_plug_as_str != '':
conn = self._add_connection(from_plug_as_str, to_plug_as_str)
for plug in (conn.from_plug, conn.to_plug):
plug_capacity = inventory.read_plug_capacity(plug)
if plug_capacity is not None:
plug.set_current_capacity_constraint(plug_capacity)
rows = inventory.query("SELECT * FROM power_output_specs")
for row in rows:
# print row
# ('ups1_o1', 16.0),
(to_plug_as_str, max_amps_as_str) = row
to_plug = self._get_plug(to_plug_as_str)
to_plug.set_current_capacity_constraint(float(max_amps_as_str))
for machine in self.machines.values():
machine_name = machine.name
# find its rack location
try:
rack_id, rack_slot_index = inventory.get_machine_rack_location(machine_name)
machine.rack_id = rack_id
machine.rack_slot_index = rack_slot_index
except SimpaDbUtil.TableAttrNotFound:
pass
if re.match('[a-z]+.._..', machine_name):
# machine_name is a group of machines such as physix80_83
# in this case, we use a hack : the type and power consumption is based on the first machine of this group (in this example physix80)
machine_name = '_'.join(machine_name.split('_')[0:-1])
# print(machine_name)
machine_spec_id = None
try:
# assert machine_spec_id != '', 'non-empty value expected for machine_spec_id for machine %s' % machine_name
machine_spec_id = inventory.machine_name_to_machine_spec_id(machine_name)
if machine_spec_id == '':
machine_spec_id = None # some simple 'machines' such as powerext003 have undefined machine_spec_id
except MachineSpecIdNotFound:
pass
if machine_spec_id is not None:
power_consumption = inventory.machine_spec_id_to_power_consumption(machine_spec_id)
if power_consumption is not None:
machine.power_consumption = power_consumption
def get_connection_to(self, to_plug):
for connection in self.connections:
if connection.to_plug == to_plug:
return connection
return None
def _get_machine(self, machine_name):
if machine_name not in self.machines:
self.machines[machine_name] = Machine(machine_name, self)
return self.machines[machine_name]
def _get_plug(self, plug_as_str):
elements = plug_as_str.split('_')
plug_name = elements[-1]
machine_name = plug_as_str[0:-(len(plug_name) + 1)]
machine = self._get_machine(machine_name)
return machine.get_plug(plug_name)
def _add_connection(self, from_plug_as_str, to_plug_as_str):
from_plug = self._get_plug(from_plug_as_str)
to_plug = self._get_plug(to_plug_as_str)
conn = Connection(from_plug, to_plug)
self.connections.append(conn)
return conn
def __str__(self):
s = ''
for c in self.connections:
s += str(c) + '\n'
return s
class CableColorer(object):
def get_cable_color(self, cable):
"""
:param Connection cable:
"""
raise NotImplementedError
class SimpleColorer(CableColorer):
def get_cable_color(self, cable):
"""
:param Connection cable:
"""
power_consumption = cable.get_power_consumption()
amperes = power_consumption / 220.0
capacity = cable.get_max_amperes()
saturation = amperes / capacity
if saturation > 1.0:
color = '/svg/red'
elif saturation > 0.75:
color = '/svg/orange'
elif saturation < 0.001:
color = '/svg/black' # probably an error
else:
color = '/svg/green'
return color
class RampColorer(CableColorer):
@staticmethod
def hotness_to_hsv_color(hotness):
"""
:param float hotness: temperature of the wire ratio (0.0 : cold -> 1.0 : hot)
"""
clamped_hotness = max(min(hotness, 1.0), 0.0)
return "%f, 1.0, 0.8" % ((clamped_hotness) * 0.1 + 0.23)
def get_cable_color(self, cable):
"""
:param Connection cable:
"""
power_consumption = cable.get_power_consumption()
amperes = power_consumption / 220.0
capacity = cable.get_max_amperes()
saturation = amperes / capacity
color = None
# print(cable.from_plug.machine.name, cable.to_plug.machine.name, cable.get_power_provider().name)
power_is_backed_up = cable.get_power_provider().name == 'ups3'
if power_is_backed_up:
# greenish colors
color = RampColorer.hotness_to_hsv_color(pow(saturation, 4.0))
else:
# blueish colors using the pretty ylgnbu9 color palette
clamped_saturation = max(min(saturation, 1.0), 0.0)
color = '/ylgnbu9/%d' % (int(clamped_saturation * 5) + 4)
if saturation < 0.001:
color = '/svg/black' # probably an error
elif saturation > 1.0:
color = '/svg/red' # draw overloaded cables in red
return color
def power_config_to_svg(power_config, svg_file_path):
"""
creates a svg diagram representing the input power configuration
:param PowerConfig power_config: the input power config
"""
graph = pygraphviz.AGraph()
graph.graph_attr['overlap'] = 'false'
graph.graph_attr['splines'] = 'true'
graph.graph_attr['rankdir'] = 'LR' # to get hrizontal tree rather than vertical
graph.edge_attr['colorscheme'] = 'rdylgn9' # 'brbg11'
graph.node_attr['shape'] = 'box'
graph.node_attr['height'] = 0.3 # default 0.5 inches
graph.node_attr['fontname'] = 'Helvetica' # default : Times-Roman
graph.edge_attr['fontsize'] = 10 # default : 14 pt
graph.edge_attr['len'] = 1.5 # default : 1.0
# graph.add_node('a')
# graph.add_node('b')
# graph.add_edge(u'a',u'b',color='blue')
# graph.add_edge(u'b',u'a',color='blue')
racks = {}
for con in power_config.connections:
for machine in [con.from_plug.machine, con.to_plug.machine]:
rack_id = machine.rack_id
if rack_id is not None:
if rack_id not in racks.keys():
rack = {}
rack['name'] = rack_id
rack['machines'] = []
racks[rack_id] = rack
rack = racks[rack_id]
if machine.name not in rack['machines']:
rack['machines'].append(machine)
if False:
x = 0.0
for rack in racks.itervalues():
y = 0.0
for machine in rack['machines']:
graph.add_node(machine.name, pos='%f,%f!' % (x, y)) # https://observablehq.com/@magjac/placing-graphviz-nodes-in-fixed-positions
# print(machine.name, x, y)
y += 1.0
x += 1.0
cable_colorer = RampColorer()
for con in power_config.connections:
# print(con.from_plug.machine.name, con.to_plug.machine.name)
if not con.is_redundancy_cable(): # don't display redundancy cables, as they might overlap and hide the main one
power_consumption = con.get_power_consumption()
amperes = power_consumption / 220.0
color = '/svg/green'
capacity = con.get_max_amperes()
penwidth_scaler = 0.25
if capacity is None:
max_amp = '? A'
color = '/svg/red'
penwidth = 100.0 * penwidth_scaler # make the problem clearly visible
else:
max_amp = str(capacity) + 'A'
color = cable_colorer.get_cable_color(con)
penwidth = capacity * penwidth_scaler
label = "%.1f/%s" % (amperes, max_amp)
# color='//%d' % int(9.0-amperes/capacity*8)
graph.add_edge(con.from_plug.machine.name, con.to_plug.machine.name, color=color, label=label, penwidth=penwidth)
if True:
for rack_id, rack in racks.iteritems():
# sub = graph.add_subgraph(rack, name='cluster_%s' % rack_id, rank='same')
machine_names = list(machine.name for machine in rack['machines'])
sub = graph.add_subgraph(machine_names, name='cluster_%s' % rack_id)
sub.graph_attr['label'] = rack_id
# sub.graph_attr['rank']='same'
# assert False
#graph.layout(prog='twopi')
with open('./toto.dot', 'w') as f:
f.write(graph.string())
graph.layout(prog='dot')
graph.draw(svg_file_path)