cocluto/PowerDiagram.py

514 lines
22 KiB
Python
Raw Normal View History

'''
2018-08-27 16:54:55 +02:00
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)
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
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, TableAttrNotFound
# from Lib import SimpaDbUtil
2018-08-27 16:54:55 +02:00
def add_capacity_constraints(capacity1, capacity2):
2018-08-27 16:54:55 +02:00
"""
combines 2 capacity constraints (max amperes) together
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
: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):
2018-08-27 16:54:55 +02:00
"""
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
2018-08-27 16:54:55 +02:00
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_powered_machines(self):
powered_machines = {}
powered_machines[self.name] = self
2018-08-27 16:54:55 +02:00
for conn in self.get_outgoing_connections():
conn_powered_machines = conn.to_plug.machine.get_powered_machines()
for machine in conn_powered_machines.itervalues():
if machine not in powered_machines.keys():
powered_machines[machine.name] = machine
return powered_machines
def get_power_consumption(self, worst_case_scenario=False):
"""
Returns the number of watts going through this 'machine' (which could just be a powerstrip)
:param bool worst_case_scenario: if True, computes the number of watts going through this cable in the worst case scenario, in which this cable has to provide the power supply assumung all other backup powers are dead
"""
if worst_case_scenario:
# this machine has to provide the full power to all machine it powers, assuming all the power backup providers are dead
power_consumption = 0.0
for machine in self.get_powered_machines().itervalues():
power_consumption += machine.power_consumption
else:
power_consumption = self.power_consumption
for conn in self.get_outgoing_connections():
power_consumption += conn.get_power_consumption(worst_case_scenario)
# print("machine %s : power_consumption += %f" % (self.name, conn.get_power_consumption()))
2018-08-27 16:54:55 +02:00
return power_consumption
def is_power_provider(self):
return self.name == 'edf' or re.match('^ups[0-9]*$', self.name)
class Plug(object):
2018-08-27 16:54:55 +02:00
"""
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
2018-08-27 16:54:55 +02:00
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:
2021-01-21 17:14:29 +01:00
print(str(self) + 'after incoming connection amperes limitation, capacity = ' + str(capacity))
2018-08-27 16:54:55 +02:00
else:
# apply the machine containing this plug's amperes limitation
capacity = add_capacity_constraints(capacity, self.machine.get_max_amperes())
if debug:
2021-01-21 17:14:29 +01:00
print(str(self) + 'apply the machine containing this plug s amperes limitation, capacity = ' + str(capacity))
2018-08-27 16:54:55 +02:00
# apply this plug's amperes limitation
capacity = add_capacity_constraints(capacity, self.current_capacity_constraint)
if debug:
2021-01-21 17:14:29 +01:00
print(str(self) + 'after apply this plug s amperes limitation, capacity = ' + str(capacity), self.current_capacity_constraint)
2018-08-27 16:54:55 +02:00
return capacity
2021-01-21 17:14:29 +01:00
class Connection(object):
2018-08-27 16:54:55 +02:00
"""
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)'
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
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)))
2018-08-27 16:54:55 +02:00
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()
2018-08-27 16:54:55 +02:00
def get_power_consumption(self, worst_case_scenario=False):
"""
Returns the number of watts going through this cable
:param bool worst_case_scenario: if True, computes the number of watts going through this cable in the worst case scenario, in which this cable has to provide the power supply assumung all other backup powers are dead
"""
to_machine = self.to_plug.machine
power_consumption = None
if worst_case_scenario:
2021-01-21 17:14:29 +01:00
if False: # self.is_redundancy_cable():
power_consumption = 0.0
else:
power_consumption = to_machine.get_power_consumption(worst_case_scenario)
2018-08-27 16:54:55 +02:00
else:
num_input_cables = 0
for input_plug in to_machine.input_plugs.itervalues():
input_cable = input_plug.get_incoming_connection()
assert input_cable
num_input_cables += 1
assert num_input_cables > 0
power_consumption = to_machine.get_power_consumption(worst_case_scenario) / num_input_cables
return power_consumption
2021-01-21 17:14:29 +01:00
class PowerConfig(object):
2018-08-27 16:54:55 +02:00
"""
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 = []
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
sql_source = SqlFile(simpa_db_sql_file_path)
sql_reader = SqlDatabaseReader(sql_source)
inventory = Inventory(sql_reader)
self._parse_from_inventory(inventory)
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
def _parse_from_inventory(self, inventory):
"""
:param Inventory inventory:
"""
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
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))
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
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 TableAttrNotFound:
pass
num_machines = 1
match = re.match(r'(?P<cluster_name>[a-z]+)(?P<first_index>[0-9]+)_(?P<last_index>[0-9]+)', machine_name)
if match:
# 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 = %s' % machine_name)
first_index = int(match.group('first_index'))
last_index = int(match.group('last_index'))
machine_name = '%s%02d' % (match.group('cluster_name'), first_index)
num_machines = last_index - first_index + 1
2018-08-27 16:54:55 +02:00
# print(machine_name)
# find the max power consumption of this machine
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
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
if machine_spec_id is not None:
measured_max_power_consumption = inventory.machine_to_power_consumption(machine_name)
estimated_max_power_consumption = None
chassis_power_consumption = inventory.machine_spec_id_to_power_consumption(machine_spec_id)
if chassis_power_consumption is not None:
estimated_max_power_consumption = chassis_power_consumption
try:
cpu_power_consumption = inventory.get_num_cpus(machine_name) * inventory.get_cpu_tdp(machine_name)
estimated_max_power_consumption += cpu_power_consumption
except TableAttrNotFound as e: # noqa: F841
pass # could happen for machines that have no cpu (eg simpa-switch-cisco-2)
if measured_max_power_consumption is not None:
machine.power_consumption = num_machines * measured_max_power_consumption
if estimated_max_power_consumption is not None:
# assert measured_max_power_consumption > estimated_max_power_consumption, 'the estimated power consumption (%f W) of %s is too far from is measured value (%f W)' % (estimated_max_power_consumption, machine_name, measured_max_power_consumption)
pass
elif estimated_max_power_consumption is not None:
machine.power_consumption = num_machines * estimated_max_power_consumption
2018-08-27 16:54:55 +02:00
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]
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
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)
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
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
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
def __str__(self):
s = ''
for c in self.connections:
s += str(c) + '\n'
return s
2021-01-21 17:14:29 +01:00
class CableColorer(object):
def get_cable_color(self, cable, worst_case_scenario):
"""
:param Connection cable:
"""
raise NotImplementedError
2021-01-21 17:14:29 +01:00
class SimpleColorer(CableColorer):
def get_cable_color(self, cable, worst_case_scenario):
"""
:param Connection cable:
"""
power_consumption = cable.get_power_consumption(worst_case_scenario)
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
2021-01-21 17:14:29 +01:00
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, worst_case_scenario):
"""
:param Connection cable:
"""
power_consumption = cable.get_power_consumption(worst_case_scenario)
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
2018-08-27 16:54:55 +02:00
2021-01-21 17:14:29 +01:00
def power_config_to_svg(power_config, svg_file_path, worst_case_scenario=True):
2018-08-27 16:54:55 +02:00
"""
creates a svg diagram representing the input power configuration
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
:param PowerConfig power_config: the input power config
"""
graph = pygraphviz.AGraph(strict=False) # strict=False allows more than one connection between 2 nodes
2018-08-27 16:54:55 +02:00
graph.graph_attr['overlap'] = 'false'
graph.graph_attr['splines'] = 'true'
graph.graph_attr['rankdir'] = 'LR' # to get hrizontal tree rather than vertical
2021-01-21 17:14:29 +01:00
2018-08-27 16:54:55 +02:00
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)
for machine in power_config.machines.itervalues():
graph.add_node(machine.name)
node = graph.get_node(machine.name)
machine_total_power_consumption = int(machine.get_power_consumption(worst_case_scenario=worst_case_scenario))
# node.attr['label'] = '%s (%d W)' % (machine.name, machine_total_power_consumption)
node.attr['shape'] = 'plaintext'
node.attr['label'] = '<\
<table border="1" cellborder="0" cellspacing="0">\
<tr>\
<td color="red">%s</td>\
<td bgcolor="black"><font color="white">%s W</font></td>\
</tr>\
</table>>' % (machine.name, machine_total_power_consumption)
if False:
x = 0.0
for rack in racks.itervalues():
y = 0.0
for machine in rack['machines']:
node = graph.get_node(machine.name)
node.attr['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()
2018-08-27 16:54:55 +02:00
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(worst_case_scenario=worst_case_scenario)
2018-08-27 16:54:55 +02:00
amperes = power_consumption / 220.0
color = '/svg/green'
2018-08-27 16:54:55 +02:00
capacity = con.get_max_amperes()
penwidth_scaler = 0.25
2018-08-27 16:54:55 +02:00
if capacity is None:
max_amp = '? A'
color = '/svg/red'
penwidth = 100.0 * penwidth_scaler # make the problem clearly visible
2018-08-27 16:54:55 +02:00
else:
max_amp = str(capacity) + 'A'
color = cable_colorer.get_cable_color(con, worst_case_scenario=worst_case_scenario)
penwidth = capacity * penwidth_scaler
label = "%.1f/%s" % (amperes, max_amp)
2018-08-27 16:54:55 +02:00
# color='//%d' % int(9.0-amperes/capacity*8)
2021-01-21 17:14:29 +01:00
# graph.add_edge(con.from_plug.machine.name, con.to_plug.machine.name, color="%s:%s" % (color, wsc_color), label=label, penwidth="%s:%s" % (penwidth, penwidth))
graph.add_edge(con.from_plug.machine.name, con.to_plug.machine.name, color=color, label=label, penwidth=penwidth)
2019-06-27 11:26:25 +02:00
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, style='rounded')
sub.graph_attr['label'] = rack_id
# sub.graph_attr['rank']='same'
# assert False
2021-01-21 17:14:29 +01:00
# graph.layout(prog='twopi')
with open('./toto.dot', 'w') as f:
f.write(graph.string())
graph.layout(prog='dot')
2018-08-27 16:54:55 +02:00
graph.draw(svg_file_path)