From 4d884147e63df79ccc372ff64c08fd417ed4976c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Wed, 2 Sep 2020 15:33:19 +0200 Subject: [PATCH 01/38] close #87 refactor stack plot --- hadar/analyzer/result.py | 3 ++- hadar/viewer/abc.py | 57 +++++++++++++++------------------------- 2 files changed, 23 insertions(+), 37 deletions(-) diff --git a/hadar/analyzer/result.py b/hadar/analyzer/result.py index 74a5b02..37f3ce9 100644 --- a/hadar/analyzer/result.py +++ b/hadar/analyzer/result.py @@ -4,6 +4,7 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. +from copy import deepcopy from functools import reduce from typing import TypeVar, List, Generic, Type @@ -667,4 +668,4 @@ def _append(self, index: Index): if len(self.indexes) == NetworkFluentAPISelector.FULL_DESCRIPTION: return self.analyzer.filter(self.indexes) else: - return NetworkFluentAPISelector(self.indexes, self.analyzer) + return NetworkFluentAPISelector(deepcopy(self.indexes), self.analyzer) diff --git a/hadar/viewer/abc.py b/hadar/viewer/abc.py index bead62a..0dc43a4 100644 --- a/hadar/viewer/abc.py +++ b/hadar/viewer/abc.py @@ -5,7 +5,8 @@ # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. from abc import ABC, abstractmethod -from typing import List, Tuple, Dict +from copy import deepcopy +from typing import List, Tuple, Dict, Optional import numpy as np import pandas as pd @@ -484,26 +485,21 @@ def stack(self, scn: int = 0, prod_kind: str = 'used', cons_kind: str = 'asked') :param cons_kind: select which cons to stack : 'asked' or 'given' :return: plotly figure or jupyter widget to plot """ - c, p, s, b, ve, vi = self.agg.get_elements_inside(node=self.node, network=self.network) + def extract(query, value_col: str, sort_col: Optional[str] = 'cost', id_col: str = 'name'): + data = query.time() + if sort_col: + data.sort_values(sort_col, ascending=False, inplace=True) + ids = data.index.get_level_values(id_col).unique() + return [(i, data.loc[i][value_col].sort_index().values) for i in ids] + c, p, s, b, ve, vi = self.agg.get_elements_inside(node=self.node, network=self.network) areas = [] - # stack production with area - if p > 0: - prod = self.agg.network(self.network).scn(scn).node(self.node).production().time().sort_values('cost', ascending=False) - for i, name in enumerate(prod.index.get_level_values('name').unique()): - areas.append((name, prod.loc[name][prod_kind].sort_index().values)) - - # add storage output flow - if s > 0: - stor = self.agg.network(self.network).scn(scn).node(self.node).storage().time().sort_values('cost', ascending=False) - for i, name in enumerate(stor.index.get_level_values('name').unique()): - areas.append((name, stor.loc[name]['flow_out'].sort_index().values)) - - # Add converter importation - if vi > 0: - conv = self.agg.network(self.network).scn(scn).node(self.node).from_converter().time() - for i, name in enumerate(conv.index.get_level_values('name').unique()): - areas.append((name, conv.loc[name, 'flow'].sort_index().values)) + areas += extract(query=self.agg.network(self.network).scn(scn).node(self.node).production(), + value_col=prod_kind) if p > 0 else [] + areas += extract(query=self.agg.network(self.network).scn(scn).node(self.node).storage(), + value_col='flow_out') if s > 0 else [] + areas += extract(query=self.agg.network(self.network).scn(scn).node(self.node).from_converter(), + value_col='flow', sort_col=None) if vi > 0 else [] # add import in production stack balance = self.agg.get_balance(node=self.node, network=self.network)[scn] @@ -512,23 +508,12 @@ def stack(self, scn: int = 0, prod_kind: str = 'used', cons_kind: str = 'asked') areas.append(('import', im)) lines = [] - # Stack consumptions with line - if c > 0: - cons = self.agg.network(self.network).scn(scn).node(self.node).consumption().time().sort_values('cost', ascending=False) - for i, name in enumerate(cons.index.get_level_values('name').unique()): - lines.append((name, cons.loc[name][cons_kind].sort_index().values)) - - # add storage output intput - if s > 0: - stor = self.agg.network(self.network).scn(scn).node(self.node).storage().time().sort_values('cost', ascending=False) - for i, name in enumerate(stor.index.get_level_values('name').unique()): - lines.append((name, stor.loc[name]['flow_in'].sort_index().values)) - - # Add converter exportation - if ve > 0: - conv = self.agg.network(self.network).scn(scn).node(self.node).to_converter().time() - for i, name in enumerate(conv.index.get_level_values('name').unique()): - lines.append((name, conv.loc[name, 'flow'].sort_index().values)) + lines += extract(query=self.agg.network(self.network).scn(scn).node(self.node).consumption(), + value_col=cons_kind) if c > 0 else [] + lines += extract(query=self.agg.network(self.network).scn(scn).node(self.node).storage(), + value_col='flow_in') if s > 0 else [] + lines += extract(query=self.agg.network(self.network).scn(scn).node(self.node).to_converter(), + value_col='flow', sort_col=None) if ve > 0 else [] # Add export in consumption stack exp = np.clip(balance, 0, None) From c92472a94c0c29e041303c69c652b1d4d5e85901 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Wed, 2 Sep 2020 15:59:25 +0200 Subject: [PATCH 02/38] close #90 Remove cost in Result --- hadar/analyzer/result.py | 33 +++++----- hadar/optimizer/output.py | 24 ++----- tests/analyzer/test_result.py | 16 ++--- tests/optimizer/it/test_optimizer.py | 82 ++++++++++++------------ tests/optimizer/lp/test_mapper.py | 6 +- tests/optimizer/lp/test_optimizer.py | 2 +- tests/optimizer/remote/test_optimizer.py | 4 +- tests/optimizer/test_output.py | 6 +- tests/utils.py | 6 -- 9 files changed, 83 insertions(+), 96 deletions(-) diff --git a/hadar/analyzer/result.py b/hadar/analyzer/result.py index 37f3ce9..2d97868 100644 --- a/hadar/analyzer/result.py +++ b/hadar/analyzer/result.py @@ -196,14 +196,15 @@ def _build_consumption(study: Study, result: Result): n_cons = 0 for n, net in result.networks.items(): for node in net.nodes.keys(): - for i, c in enumerate(net.nodes[node].consumptions): + for i, rc in enumerate(net.nodes[node].consumptions): slices = cons.index[n_cons * h * scn: (n_cons + 1) * h * scn] - cons.loc[slices, 'cost'] = c.cost.flatten() - cons.loc[slices, 'name'] = c.name + sc = study.networks[n].nodes[node].consumptions[i] + cons.loc[slices, 'cost'] = sc.cost.flatten() + cons.loc[slices, 'name'] = rc.name cons.loc[slices, 'node'] = node cons.loc[slices, 'network'] = n - cons.loc[slices, 'asked'] = study.networks[n].nodes[node].consumptions[i].quantity.flatten() - cons.loc[slices, 'given'] = c.quantity.flatten() + cons.loc[slices, 'asked'] = sc.quantity.flatten() + cons.loc[slices, 'given'] = rc.quantity.flatten() cons.loc[slices, 't'] = np.tile(np.arange(h), scn) cons.loc[slices, 'scn'] = np.repeat(np.arange(scn), h) @@ -231,14 +232,15 @@ def _build_production(study: Study, result: Result): n_prod = 0 for n, net in result.networks.items(): for node in net.nodes.keys(): - for i, c in enumerate(net.nodes[node].productions): + for i, rp in enumerate(net.nodes[node].productions): slices = prod.index[n_prod * h * scn: (n_prod + 1) * h * scn] - prod.loc[slices, 'cost'] = c.cost.flatten() - prod.loc[slices, 'name'] = c.name + sp = study.networks[n].nodes[node].productions[i] + prod.loc[slices, 'cost'] = sp.cost.flatten() + prod.loc[slices, 'name'] = rp.name prod.loc[slices, 'node'] = node prod.loc[slices, 'network'] = n - prod.loc[slices, 'avail'] = study.networks[n].nodes[node].productions[i].quantity.flatten() - prod.loc[slices, 'used'] = c.quantity.flatten() + prod.loc[slices, 'avail'] = sp.quantity.flatten() + prod.loc[slices, 'used'] = rp.quantity.flatten() prod.loc[slices, 't'] = np.tile(np.arange(h), scn) prod.loc[slices, 'scn'] = np.repeat(np.arange(scn), h) @@ -315,14 +317,15 @@ def _build_link(study: Study, result: Result): n_link = 0 for n, net in result.networks.items(): for node in net.nodes.keys(): - for i, c in enumerate(net.nodes[node].links): + for i, rl in enumerate(net.nodes[node].links): slices = link.index[n_link * h * scn: (n_link + 1) * h * scn] - link.loc[slices, 'cost'] = c.cost.flatten() - link.loc[slices, 'dest'] = c.dest + sl = study.networks[n].nodes[node].links[i] + link.loc[slices, 'cost'] = sl.cost.flatten() + link.loc[slices, 'dest'] = rl.dest link.loc[slices, 'node'] = node link.loc[slices, 'network'] = n - link.loc[slices, 'avail'] = study.networks[n].nodes[node].links[i].quantity.flatten() - link.loc[slices, 'used'] = c.quantity.flatten() + link.loc[slices, 'avail'] = sl.quantity.flatten() + link.loc[slices, 'used'] = rl.quantity.flatten() link.loc[slices, 't'] = np.tile(np.arange(h), scn) link.loc[slices, 'scn'] = np.repeat(np.arange(scn), h) diff --git a/hadar/optimizer/output.py b/hadar/optimizer/output.py index 3a58174..e26c332 100644 --- a/hadar/optimizer/output.py +++ b/hadar/optimizer/output.py @@ -19,15 +19,13 @@ class OutputConsumption(JSON): """ Consumption element """ - def __init__(self, quantity: Union[np.ndarray, list], cost: Union[np.ndarray, list], name: str = ''): + def __init__(self, quantity: Union[np.ndarray, list], name: str = ''): """ Create instance. :param quantity: quantity matched by node - :param cost: cost of unavailability :param name: consumption name (unique in a node) """ - self.cost = np.array(cost) self.quantity = np.array(quantity) self.name = name @@ -41,16 +39,14 @@ class OutputProduction(JSON): """ Production element """ - def __init__(self, quantity: Union[np.ndarray, list], cost: Union[np.ndarray, list], name: str = 'in'): + def __init__(self, quantity: Union[np.ndarray, list], name: str = 'in'): """ Create instance. :param quantity: capacity used by node - :param cost: cost of use :param name: production name (unique in a node) """ self.name = name - self.cost = np.array(cost) self.quantity = np.array(quantity) @staticmethod @@ -86,17 +82,15 @@ class OutputLink(JSON): """ Link element """ - def __init__(self, dest: str, quantity: Union[np.ndarray, list], cost: Union[np.ndarray, list]): + def __init__(self, dest: str, quantity: Union[np.ndarray, list]): """ Create instance. :param dest: destination node name :param quantity: capacity used - :param cost: cost of use """ self.dest = dest self.quantity = np.array(quantity) - self.cost = np.array(cost) @staticmethod def from_json(dict): @@ -169,15 +163,11 @@ def build_like_input(input: InputNode, fill: np.ndarray): :return: OutputNode like InputNode with all quantity at zero """ output = OutputNode(consumptions=[], productions=[], storages=[], links=[]) - output.consumptions = [OutputConsumption(name=i.name, cost=i.cost, quantity=fill) - for i in input.consumptions] - output.productions = [OutputProduction(name=i.name, cost=i.cost, quantity=fill) - for i in input.productions] - output.storages = [OutputStorage(name=i.name, capacity=fill, - flow_out=fill, flow_in=fill) + output.consumptions = [OutputConsumption(name=i.name, quantity=fill) for i in input.consumptions] + output.productions = [OutputProduction(name=i.name, quantity=fill) for i in input.productions] + output.storages = [OutputStorage(name=i.name, capacity=fill, flow_out=fill, flow_in=fill) for i in input.storages] - output.links = [OutputLink(dest=i.dest, cost=i.cost, quantity=fill) - for i in input.links] + output.links = [OutputLink(dest=i.dest, quantity=fill) for i in input.links] return output @staticmethod diff --git a/tests/analyzer/test_result.py b/tests/analyzer/test_result.py index 097fb99..204c6d5 100644 --- a/tests/analyzer/test_result.py +++ b/tests/analyzer/test_result.py @@ -67,10 +67,10 @@ def setUp(self) -> None: .build() out = { - 'a': OutputNode(consumptions=[OutputConsumption(cost=np.ones((2, 3)) * 10 ** 3, quantity=[[20, 2, 2], [2, 20, 20]], name='load'), - OutputConsumption(cost=np.ones((2, 3)) * 10 ** 3, quantity=[[30, 3, 3], [3, 30, 30]], name='car')], + 'a': OutputNode(consumptions=[OutputConsumption(quantity=[[20, 2, 2], [2, 20, 20]], name='load'), + OutputConsumption(quantity=[[30, 3, 3], [3, 30, 30]], name='car')], productions=[], storages=[], links=[]), - 'b': OutputNode(consumptions=[OutputConsumption(cost=np.ones((2, 3)) * 10 ** 3, quantity=[[20, 2, 2], [2, 20, 20]], name='load')], + 'b': OutputNode(consumptions=[OutputConsumption(quantity=[[20, 2, 2], [2, 20, 20]], name='load')], productions=[], storages=[], links=[]) } self.result = Result(networks={'default': OutputNetwork(nodes=out)}, converters={}) @@ -121,11 +121,11 @@ def setUp(self) -> None: .build() out = { - 'a': OutputNode(productions=[OutputProduction(cost=np.ones((2, 3)) * 10, quantity=[[30, 3, 3], [3, 30, 30]], name='prod')], + 'a': OutputNode(productions=[OutputProduction(quantity=[[30, 3, 3], [3, 30, 30]], name='prod')], consumptions=[], storages=[], links=[]), - 'b': OutputNode(productions=[OutputProduction(cost=np.ones((2, 3)) * 20, quantity=[[10, 1, 1], [1, 10, 10]], name='prod'), - OutputProduction(cost=np.ones((2, 3)) * 20, quantity=[[20, 2, 2], [2, 20, 20]], name='nuclear')], + 'b': OutputNode(productions=[OutputProduction(quantity=[[10, 1, 1], [1, 10, 10]], name='prod'), + OutputProduction(quantity=[[20, 2, 2], [2, 20, 20]], name='nuclear')], consumptions=[], storages=[], links=[]) } @@ -242,8 +242,8 @@ def setUp(self) -> None: blank_node = OutputNode(consumptions=[], productions=[], storages=[], links=[]) out = { 'a': OutputNode(consumptions=[], productions=[], storages=[], - links=[OutputLink(dest='b', quantity=[[10, 1, 1], [1, 10, 10]], cost=np.ones((2, 3)) * 2), - OutputLink(dest='c', quantity=[[20, 2, 2], [2, 20, 20]], cost=np.ones((2, 3)) * 2)]), + links=[OutputLink(dest='b', quantity=[[10, 1, 1], [1, 10, 10]]), + OutputLink(dest='c', quantity=[[20, 2, 2], [2, 20, 20]])]), 'b': blank_node, 'c': blank_node } diff --git a/tests/optimizer/it/test_optimizer.py b/tests/optimizer/it/test_optimizer.py index a752a67..5dbb696 100644 --- a/tests/optimizer/it/test_optimizer.py +++ b/tests/optimizer/it/test_optimizer.py @@ -46,11 +46,11 @@ def test_merit_order(self): nodes_expected = dict() nodes_expected['a'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[30, 6, 6], [6, 30, 30]], name='load')], + consumptions=[hd.OutputConsumption(quantity=[[30, 6, 6], [6, 30, 30]], name='load')], productions=[ - hd.OutputProduction(name='nuclear', cost=20, quantity=[[15, 3, 3], [3, 15, 15]]), - hd.OutputProduction(name='solar', cost=10, quantity=[[10, 2, 2], [2, 10, 10]]), - hd.OutputProduction(name='oil', cost=30, quantity=[[5, 1, 1], [1, 5, 5]])], + hd.OutputProduction(name='nuclear', quantity=[[15, 3, 3], [3, 15, 15]]), + hd.OutputProduction(name='solar', quantity=[[10, 2, 2], [2, 10, 10]]), + hd.OutputProduction(name='oil', quantity=[[5, 1, 1], [1, 5, 5]])], storages=[], links=[]) @@ -86,14 +86,14 @@ def test_exchange_two_nodes(self): nodes_expected = {} nodes_expected['a'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[20, 200]], name='load')], - productions=[hd.OutputProduction(cost=10, quantity=[[30, 300]], name='prod')], + consumptions=[hd.OutputConsumption(quantity=[[20, 200]], name='load')], + productions=[hd.OutputProduction(quantity=[[30, 300]], name='prod')], storages=[], - links=[hd.OutputLink(dest='b', quantity=[[10, 100]], cost=2)]) + links=[hd.OutputLink(dest='b', quantity=[[10, 100]])]) nodes_expected['b'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[20, 200]], name='load')], - productions=[hd.OutputProduction(cost=20, quantity=[[10, 100]], name='prod')], + consumptions=[hd.OutputConsumption(quantity=[[20, 200]], name='load')], + productions=[hd.OutputProduction(quantity=[[10, 100]], name='prod')], storages=[], links=[]) @@ -138,21 +138,21 @@ def test_exchange_two_concurrent_nodes(self): nodes_expected = {} nodes_expected['a'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[10]], name='load')], - productions=[hd.OutputProduction(cost=10, quantity=[[30]], name='nuclear')], + consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], + productions=[hd.OutputProduction(quantity=[[30]], name='nuclear')], storages=[], - links=[hd.OutputLink(dest='b', quantity=[[10]], cost=2), - hd.OutputLink(dest='c', quantity=[[10]], cost=2)]) + links=[hd.OutputLink(dest='b', quantity=[[10]]), + hd.OutputLink(dest='c', quantity=[[10]])]) nodes_expected['b'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[10]], name='load')], - productions=[hd.OutputProduction(cost=20, quantity=[[0]], name='nuclear')], + consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], + productions=[hd.OutputProduction(quantity=[[0]], name='nuclear')], storages=[], links=[]) nodes_expected['c'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[10]], name='load')], - productions=[hd.OutputProduction(cost=20, quantity=[[0]], name='nuclear')], + consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], + productions=[hd.OutputProduction(quantity=[[0]], name='nuclear')], storages=[], links=[]) @@ -183,18 +183,18 @@ def test_exchange_link_saturation(self): .build() nodes_expected = {} - nodes_expected['a'] = hd.OutputNode(productions=[hd.OutputProduction(cost=10, quantity=[[20]], name='nuclear')], - links=[hd.OutputLink(dest='b', quantity=[[20]], cost=2)], + nodes_expected['a'] = hd.OutputNode(productions=[hd.OutputProduction(quantity=[[20]], name='nuclear')], + links=[hd.OutputLink(dest='b', quantity=[[20]])], storages=[], consumptions=[]) nodes_expected['b'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[10]], name='load')], - links=[hd.OutputLink(dest='c', quantity=[[10]], cost=2)], + consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], + links=[hd.OutputLink(dest='c', quantity=[[10]])], storages=[], productions=[]) nodes_expected['c'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[10]], name='load')], + consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], productions=[], storages=[], links=[]) @@ -236,20 +236,20 @@ def test_consumer_cancel_exchange(self): nodes_expected = {} nodes_expected['a'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[10]], name='load')], - productions=[hd.OutputProduction(cost=10, quantity=[[20]], name='nuclear')], + consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], + productions=[hd.OutputProduction(quantity=[[20]], name='nuclear')], storages=[], - links=[hd.OutputLink(dest='b', quantity=[[10]], cost=2)]) + links=[hd.OutputLink(dest='b', quantity=[[10]])]) nodes_expected['b'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[5]], name='load')], - productions=[hd.OutputProduction(cost=20, quantity=[[5]], name='nuclear')], + consumptions=[hd.OutputConsumption(quantity=[[5]], name='load')], + productions=[hd.OutputProduction(quantity=[[5]], name='nuclear')], storages=[], - links=[hd.OutputLink(dest='c', quantity=[[10]], cost=2)]) + links=[hd.OutputLink(dest='c', quantity=[[10]])]) nodes_expected['c'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[20]], name='load')], - productions=[hd.OutputProduction(cost=10, quantity=[[10]], name='nuclear')], + consumptions=[hd.OutputConsumption(quantity=[[20]], name='load')], + productions=[hd.OutputProduction(quantity=[[10]], name='nuclear')], storages=[], links=[]) @@ -302,16 +302,16 @@ def test_many_links_on_node(self): nodes_expected = {} nodes_expected['a'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[10, 10]], name='load')], - productions=[hd.OutputProduction(cost=80, quantity=[[0, 5]], name='gas')], - storages=[], links=[hd.OutputLink(dest='b', quantity=[[0, 10]], cost=10)]) + consumptions=[hd.OutputConsumption(quantity=[[10, 10]], name='load')], + productions=[hd.OutputProduction(quantity=[[0, 5]], name='gas')], + storages=[], links=[hd.OutputLink(dest='b', quantity=[[0, 10]])]) nodes_expected['b'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[15, 25]], name='load')], + consumptions=[hd.OutputConsumption(quantity=[[15, 25]], name='load')], storages=[], productions=[], links=[]) nodes_expected['c'] = hd.OutputNode( - productions=[hd.OutputProduction(cost=50, quantity=[[25, 30]], name='nuclear')], + productions=[hd.OutputProduction(quantity=[[25, 30]], name='nuclear')], storages=[], links=[], consumptions=[]) res = self.optimizer.solve(study) @@ -340,11 +340,11 @@ def test_storage(self): nodes_expected = dict() nodes_expected['a'] = hd.OutputNode( - productions=[hd.OutputProduction(cost=20, quantity=[[10, 10, 10, 0]], name='nuclear')], - storages=[], consumptions=[], links=[hd.OutputLink(dest='b', quantity=[[10, 10, 10, 0]], cost=1)]) + productions=[hd.OutputProduction(quantity=[[10, 10, 10, 0]], name='nuclear')], + storages=[], consumptions=[], links=[hd.OutputLink(dest='b', quantity=[[10, 10, 10, 0]])]) nodes_expected['b'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10 ** 6, quantity=[[20, 10, 0, 10]], name='load')], + consumptions=[hd.OutputConsumption(quantity=[[20, 10, 0, 10]], name='load')], storages=[hd.OutputStorage(name='cell', capacity=[[5, 5, 10, 0]], flow_in=[[0, 0, 10, 0]], flow_out=[[10, 0, 0, 10]])], productions=[], links=[]) @@ -371,15 +371,15 @@ def test_multi_energies(self): networks_expected = dict() networks_expected['elec'] = hd.OutputNetwork(nodes={'a': hd.OutputNode( - consumptions=[hd.OutputConsumption(cost=10**6, quantity=[[10]], name='load')], + consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], storages=[], productions=[], links=[])}) networks_expected['gas'] = hd.OutputNetwork(nodes={'b': hd.OutputNode( - productions=[hd.OutputProduction(cost=10, quantity=[[12.5]], name='central')], + productions=[hd.OutputProduction(quantity=[[12.5]], name='central')], storages=[], consumptions=[], links=[])}) networks_expected['coat'] = hd.OutputNetwork(nodes={'c': hd.OutputNode( - productions=[hd.OutputProduction(cost=10, quantity=[[20]], name='central')], + productions=[hd.OutputProduction(quantity=[[20]], name='central')], storages=[], consumptions=[], links=[])}) converter_expected = hd.OutputConverter(name='conv', flow_src={('gas', 'b'): [[12.5]], ('coat', 'c'): [[20]]}, flow_dest=[[10]]) diff --git a/tests/optimizer/lp/test_mapper.py b/tests/optimizer/lp/test_mapper.py index 23b4b5e..cbe2852 100644 --- a/tests/optimizer/lp/test_mapper.py +++ b/tests/optimizer/lp/test_mapper.py @@ -171,7 +171,7 @@ def test_map_consumption(self): vars=LPNode(consumptions=out_cons_1, productions=[], storages=[], links=[])) # Expected - cons = OutputConsumption(name='load', quantity=[[5, 0], [0, 15]], cost=[[.01, .1], [.02, .2]]) + cons = OutputConsumption(name='load', quantity=[[5, 0], [0, 15]]) nodes = {'a': OutputNode(consumptions=[cons], productions=[], storages=[], links=[])} expected = Result(networks={'default': OutputNetwork(nodes=nodes)}, converters={}) @@ -196,7 +196,7 @@ def test_map_production(self): vars=LPNode(consumptions=[], productions=out_prod_1, storages=[], links=[])) # Expected - prod = OutputProduction(name='nuclear', quantity=[[12, 0], [0, 112]], cost=[[0.12, 0.2], [0.21, 0.02]]) + prod = OutputProduction(name='nuclear', quantity=[[12, 0], [0, 112]]) nodes = {'a': OutputNode(consumptions=[], productions=[prod], storages=[], links=[])} expected = Result(networks={'default': OutputNetwork(nodes=nodes)}, converters={}) @@ -253,7 +253,7 @@ def test_map_link(self): vars=LPNode(consumptions=[], productions=[], storages=[], links=out_link_1)) # Expected - link = OutputLink(dest='be', quantity=[[8, 0], [0, 18]], cost=[[.01, .3], [.02, .03]]) + link = OutputLink(dest='be', quantity=[[8, 0], [0, 18]]) nodes = {'a': OutputNode(consumptions=[], productions=[], storages=[], links=[link]), 'be': OutputNode(consumptions=[], productions=[], storages=[], links=[])} expected = Result(networks={'default': OutputNetwork(nodes=nodes)}, converters={}) diff --git a/tests/optimizer/lp/test_optimizer.py b/tests/optimizer/lp/test_optimizer.py index 4f972ac..e85d6ec 100644 --- a/tests/optimizer/lp/test_optimizer.py +++ b/tests/optimizer/lp/test_optimizer.py @@ -296,7 +296,7 @@ def test_solve(self): .build() # Expected - out_node = OutputNode(consumptions=[OutputConsumption(name='load', cost=10, quantity=[0])], + out_node = OutputNode(consumptions=[OutputConsumption(name='load', quantity=[0])], productions=[], storages=[], links=[]) out_conv = OutputConverter(name='conv', flow_src={('gas', 'a'): [0]}, flow_dest=[0]) exp_result = Result(networks={'gas': OutputNetwork(nodes={'a': out_node})}, diff --git a/tests/optimizer/remote/test_optimizer.py b/tests/optimizer/remote/test_optimizer.py index 2d380b6..3a77fef 100644 --- a/tests/optimizer/remote/test_optimizer.py +++ b/tests/optimizer/remote/test_optimizer.py @@ -32,7 +32,7 @@ def do_POST(self): def do_GET(self): assert '/api/v1/result/123?token=' == self.path - nodes = {'a': OutputNode(consumptions=[OutputConsumption(cost=0, quantity=[0], name='load')], + nodes = {'a': OutputNode(consumptions=[OutputConsumption(quantity=[0], name='load')], productions=[], storages=[], links=[])} res = Result(networks={'default': OutputNetwork(nodes=nodes)}, converters={}) @@ -53,7 +53,7 @@ def setUp(self) -> None: self.study = Study(horizon=1) \ .network().node('a').consumption(cost=0, quantity=[0], name='load').build() - nodes = {'a': OutputNode(consumptions=[OutputConsumption(cost=0, quantity=[0], name='load')], + nodes = {'a': OutputNode(consumptions=[OutputConsumption(quantity=[0], name='load')], productions=[], storages=[], links=[])} self.result = Result(networks={'default': OutputNetwork(nodes=nodes)}, converters={}) diff --git a/tests/optimizer/test_output.py b/tests/optimizer/test_output.py index fc860a6..544d599 100644 --- a/tests/optimizer/test_output.py +++ b/tests/optimizer/test_output.py @@ -13,9 +13,9 @@ class TestResult(unittest.TestCase): def test_json(self): result = Result(networks={'default': OutputNetwork(nodes={'a': OutputNode( - consumptions=[OutputConsumption(name='load', cost=[[1]], quantity=[[1]])], - productions=[OutputProduction(name='prod', cost=[[1]], quantity=[[1]])], - links=[OutputLink(dest='b', cost=[[1]], quantity=[[1]])], + consumptions=[OutputConsumption(name='load', quantity=[[1]])], + productions=[OutputProduction(name='prod', quantity=[[1]])], + links=[OutputLink(dest='b', quantity=[[1]])], storages=[OutputStorage(name='cell', capacity=[[1]], flow_in=[[1]], flow_out=[[1]])])})}, converters={'cell': OutputConverter(name='conv', flow_src={('elec', 'b'): [[1]]}, flow_dest=[[1]])}) diff --git a/tests/utils.py b/tests/utils.py index 7df5d81..b3c80e6 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -26,8 +26,6 @@ def assert_result(self, expected: Result, result: Result): "Consumption for node {} has different name".format(name_node)) np.testing.assert_array_equal(cons_expected.quantity, cons_res.quantity, 'Consumption {} for node {} has different quantity'.format(cons_expected.name, name_node)) - np.testing.assert_array_equal(cons_expected.cost, cons_res.cost, - 'Consumption {} for node {} has different cost'.format(cons_expected.name, name_node)) # Productions for prod_expected, prod_res in zip(node.productions, res.productions): @@ -35,8 +33,6 @@ def assert_result(self, expected: Result, result: Result): "Production for node {} has different name".format(name_node)) np.testing.assert_array_equal(prod_expected.quantity, prod_res.quantity, 'Production {} for node {} has different quantity'.format(prod_expected.name, name_node)) - np.testing.assert_array_equal(prod_expected.cost, prod_res.cost, - 'Production {} for node {} has different cost'.format(prod_expected.name, name_node)) # Storage for stor_expected, stor_res in zip(node.storages, res.storages): @@ -55,8 +51,6 @@ def assert_result(self, expected: Result, result: Result): "Link for node {} has different name".format(name_node)) np.testing.assert_array_equal(link_expected.quantity, link_res.quantity, 'Link {} for node {} has different quantity'.format(link_expected.dest, name_node)) - np.testing.assert_array_equal(link_expected.cost, link_res.cost, - 'Link {} for node {} has different cost'.format(link_expected.dest, name_node)) # Converter for name, exp in expected.converters.items(): From b269121445724868aff4981f6e16e90202ada2e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Thu, 3 Sep 2020 12:13:43 +0200 Subject: [PATCH 03/38] WIP: #89 create numerical value object --- hadar/optimizer/numeric.py | 119 ++++++++++++++++++++++++++++++++ tests/optimizer/test_numeric.py | 48 +++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 hadar/optimizer/numeric.py create mode 100644 tests/optimizer/test_numeric.py diff --git a/hadar/optimizer/numeric.py b/hadar/optimizer/numeric.py new file mode 100644 index 0000000..5ef5116 --- /dev/null +++ b/hadar/optimizer/numeric.py @@ -0,0 +1,119 @@ +# Copyright (c) 2019-2020, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Apache License, version 2.0. +# If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. +# SPDX-License-Identifier: Apache-2.0 +# This file is part of hadar-simulator, a python adequacy library for everyone. +import numpy as np + +from abc import ABC, abstractmethod +from typing import TypeVar, Generic, Union, List + +from hadar.optimizer.input import JSON + +T = TypeVar('T') + + +class NumericalValue(JSON, ABC, Generic[T]): + def __init__(self, value: T, horizon: int, nb_scn: int): + self.value = value + self.horizon = horizon + self.nb_scn = nb_scn + + @abstractmethod + def __getitem__(self, item) -> float: + pass + + @abstractmethod + def flatten(self) -> np.ndarray: + pass + + +class ScalarNumericalValue(NumericalValue[float]): + def __getitem__(self, item) -> float: + i, j = item + if i >= self.nb_scn: + raise IndexError('There are %d scenario you ask the %dth' % (self.nb_scn, i)) + if j >= self.horizon: + raise IndexError('There are %d time step you ask the %dth' % (self.horizon, j)) + return self.value + + def flatten(self) -> np.ndarray: + return np.ones(self.horizon * self.nb_scn) * self.value + + @staticmethod + def from_json(dict): + return ScalarNumericalValue(**dict) + + +class MatrixNumericalValue(NumericalValue[np.ndarray]): + def __getitem__(self, item) -> float: + i, j = item + return self.value[i, j] + + def flatten(self) -> np.ndarray: + return self.value.flatten() + + @staticmethod + def from_json(dict): + dict['value'] = np.ndarray(dict['value']) + MatrixNumericalValue(**dict) + + +class RowNumericValue(NumericalValue[np.ndarray]): + def __getitem__(self, item) -> float: + i, j = item + if i >= self.nb_scn: + raise IndexError('There are %d scenario you ask the %dth' % (self.nb_scn, i)) + return self.value[j] + + def flatten(self) -> np.ndarray: + return np.tile(self.value, self.nb_scn) + + @staticmethod + def from_json(dict): + dict['value'] = np.ndarray(dict['value']) + MatrixNumericalValue(**dict) + + +class ColumnNumericValue(NumericalValue[np.ndarray]): + def __getitem__(self, item) -> float: + i, j = item + if j >= self.horizon: + raise IndexError('There are %d time step you ask the %dth' % (self.horizon, j)) + return self.value[i, 0] + + def flatten(self) -> np.ndarray: + return np.repeat(self.value.flatten(), self.horizon) + + @staticmethod + def from_json(dict): + dict['value'] = np.ndarray(dict['value']) + MatrixNumericalValue(**dict) + + +class NumericalValueFactory: + + def __init__(self, horizon: int, nb_scn: int): + self.horizon = horizon + self.nb_scn = nb_scn + + def create(self, value: Union[float, List[float], str, np.ndarray]) -> NumericalValue: + if isinstance(value, int): + return ScalarNumericalValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) + + if isinstance(value, List): + value = np.array(value) + + if isinstance(value, np.ndarray): + # If scenario are not provided copy timeseries for each scenario + if value.shape == (self.horizon,): + return RowNumericValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) + + # If horizon are not provide extend each scenario to full horizon + if value.shape == (self.nb_scn, 1): + return ColumnNumericValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) + + # If perfect size + if value.shape == (self.nb_scn, self.horizon): + return MatrixNumericalValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) \ No newline at end of file diff --git a/tests/optimizer/test_numeric.py b/tests/optimizer/test_numeric.py new file mode 100644 index 0000000..b7bdf9e --- /dev/null +++ b/tests/optimizer/test_numeric.py @@ -0,0 +1,48 @@ +# Copyright (c) 2019-2020, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Apache License, version 2.0. +# If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. +# SPDX-License-Identifier: Apache-2.0 +# This file is part of hadar-simulator, a python adequacy library for everyone. + +import unittest +import numpy as np + +from hadar.optimizer.numeric import NumericalValueFactory, ScalarNumericalValue, MatrixNumericalValue, RowNumericValue, ColumnNumericValue + + +class TestNumericalValue(unittest.TestCase): + def setUp(self) -> None: + self.factory = NumericalValueFactory(5, 3) + + def test_scalar(self): + v = self.factory.create(42) + self.assertIsInstance(v, ScalarNumericalValue) + self.assertEqual(42, v[2, 3]) + self.assertRaises(IndexError, lambda: v[3, 1]) + self.assertRaises(IndexError, lambda: v[1, 5]) + np.testing.assert_array_equal([42] * 15, v.flatten()) + + def test_matrix(self): + v = self.factory.create(np.arange(15).reshape(3, 5)) + self.assertIsInstance(v, MatrixNumericalValue) + self.assertEqual(13, v[2, 3]) + self.assertRaises(IndexError, lambda: v[3, 1]) + self.assertRaises(IndexError, lambda: v[1, 5]) + np.testing.assert_array_equal(range(15), v.flatten()) + + def test_row(self): + v = self.factory.create(np.arange(5)) + self.assertIsInstance(v, RowNumericValue) + self.assertEqual(3, v[2, 3]) + self.assertRaises(IndexError, lambda: v[3, 1]) + self.assertRaises(IndexError, lambda: v[1, 5]) + np.testing.assert_array_equal(list(range(5)) * 3, v.flatten()) + + def test_column(self): + v = self.factory.create(np.arange(3).reshape(3, 1)) + self.assertIsInstance(v, ColumnNumericValue) + self.assertEqual(2, v[2, 3]) + self.assertRaises(IndexError, lambda: v[3, 1]) + self.assertRaises(IndexError, lambda: v[1, 5]) + np.testing.assert_array_equal([0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2], v.flatten()) \ No newline at end of file From 03af06b98e3cee37854e55334061922db751b9d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Thu, 3 Sep 2020 14:17:41 +0200 Subject: [PATCH 04/38] WIP: #89 refactoring study --- hadar/__init__.py | 2 +- hadar/optimizer/input.py | 157 +++++++++++--------------------- hadar/optimizer/numeric.py | 25 ++++- hadar/optimizer/utils.py | 48 ++++++++++ hadar/workflow/pipeline.py | 2 +- tests/optimizer/test_input.py | 18 ++-- tests/optimizer/test_numeric.py | 4 + 7 files changed, 137 insertions(+), 119 deletions(-) create mode 100644 hadar/optimizer/utils.py diff --git a/hadar/__init__.py b/hadar/__init__.py index d710292..cbefab6 100644 --- a/hadar/__init__.py +++ b/hadar/__init__.py @@ -17,7 +17,7 @@ from .viewer.html import HTMLPlotting from .analyzer.result import ResultAnalyzer -__version__ = '0.4.0' +__version__ = '0.5.0' level = os.getenv('HADAR_LOG', 'WARNING') diff --git a/hadar/optimizer/input.py b/hadar/optimizer/input.py index 21ffb46..8205f93 100644 --- a/hadar/optimizer/input.py +++ b/hadar/optimizer/input.py @@ -6,7 +6,7 @@ # This file is part of hadar-simulator, a python adequacy library for everyone. from abc import ABC, abstractmethod from copy import deepcopy -from typing import List, Union, Dict, Tuple +from typing import List, Union, Dict, Tuple, Type import numpy as np @@ -14,45 +14,10 @@ 'NetworkFluentAPISelector', 'NodeFluentAPISelector'] import hadar +from hadar.optimizer.numeric import NumericalValue, NumericalValueFactory +from hadar.optimizer.utils import JSON - -class DTO: - """ - Implement basic method for DTO objects - """ - def __hash__(self): - return hash(tuple(sorted(self.__dict__.items()))) - - def __eq__(self, other): - return isinstance(other, type(self)) and self.__dict__ == other.__dict__ - - def __str__(self): - return "{}({})".format(type(self).__name__, ", ".join(["{}={}".format(k, str(self.__dict__[k])) for k in sorted(self.__dict__)])) - - def __repr__(self): - return self.__str__() - - -class JSON(DTO, ABC): - - def to_json(self): - def convert(value): - if isinstance(value, JSON): - return value.to_json() - elif isinstance(value, dict): - return {k: convert(v) for k, v in value.items()} - elif isinstance(value, list) or isinstance(value, tuple): - return [convert(v) for v in value] - elif isinstance(value, np.ndarray): - return value.tolist() - return value - - return {k: convert(v) for k, v in self.__dict__.items()} - - @staticmethod - @abstractmethod - def from_json(dict): - pass +NumericalValueType: Type = Union[List, np.ndarray, float] class Consumption(JSON): @@ -60,7 +25,7 @@ class Consumption(JSON): Consumption element. """ - def __init__(self, quantity: Union[List, np.ndarray, float], cost: Union[List, np.ndarray, float], name: str = ''): + def __init__(self, quantity: NumericalValue, cost: NumericalValue, name: str = ''): """ Create consumption. @@ -68,8 +33,8 @@ def __init__(self, quantity: Union[List, np.ndarray, float], cost: Union[List, n :param cost: cost of unavailability :param name: name of consumption (unique for each node) """ - self.cost = np.array(cost) - self.quantity = np.array(quantity) + self.cost = cost + self.quantity = quantity self.name = name @staticmethod @@ -81,7 +46,7 @@ class Production(JSON): """ Production element """ - def __init__(self, quantity: Union[List, np.ndarray, float], cost: Union[List, np.ndarray, float], name: str = 'in'): + def __init__(self, quantity: NumericalValue, cost: NumericalValue, name: str = 'in'): """ Create production @@ -90,8 +55,8 @@ def __init__(self, quantity: Union[List, np.ndarray, float], cost: Union[List, n :param name: name of production (unique for each node) """ self.name = name - self.cost = np.array(cost) - self.quantity = np.array(quantity) + self.cost = cost + self.quantity = quantity @staticmethod def from_json(dict): @@ -102,17 +67,17 @@ class Storage(JSON): """ Storage element """ - def __init__(self, name, capacity: int, flow_in: float, flow_out: float, cost: float = 0, - init_capacity: int = 0, eff: float = 0.99): + def __init__(self, name, capacity: NumericalValue, flow_in: NumericalValue, flow_out: NumericalValue, + cost: NumericalValue, init_capacity: int, eff: NumericalValue): """ Create storage. :param capacity: maximum storage capacity (like of many quantity to use inside storage) :param flow_in: max flow into storage during on time step :param flow_out: max flow out storage during on time step - :param cost: unit cost of storage at each time-step. default 0 - :param init_capacity: initial capacity level. default 0 - :param eff: storage efficient (applied on input flow stored). default 0.99 + :param cost: unit cost of storage at each time-step. + :param init_capacity: initial capacity level. + :param eff: storage efficient (applied on input flow stored). """ self.name = name self.capacity = capacity @@ -131,7 +96,7 @@ class Link(JSON): """ Link element """ - def __init__(self, dest: str, quantity: Union[List, np.ndarray, float], cost: Union[List, np.ndarray, float]): + def __init__(self, dest: str, quantity: NumericalValue, cost: NumericalValue): """ Create link. @@ -140,8 +105,8 @@ def __init__(self, dest: str, quantity: Union[List, np.ndarray, float], cost: Un :param cost: cost of use """ self.dest = dest - self.quantity = np.array(quantity) - self.cost = np.array(cost) + self.quantity = quantity + self.cost = cost @staticmethod def from_json(dict): @@ -152,8 +117,8 @@ class Converter(JSON): """ Converter element """ - def __init__(self, name: str, src_ratios: Dict[Tuple[str, str], float], dest_network: str, dest_node: str, - cost: float, max: float,): + def __init__(self, name: str, src_ratios: Dict[Tuple[str, str], NumericalValue], dest_network: str, dest_node: str, + cost: NumericalValue, max: NumericalValue): """ Create converter. @@ -252,6 +217,7 @@ def __init__(self, horizon: int, nb_scn: int = 1, version: str = None): self.converters = dict() self.horizon = horizon self.nb_scn = nb_scn + self.factory = NumericalValueFactory(horizon=horizon, nb_scn=nb_scn) @staticmethod def from_json(dict): @@ -270,7 +236,7 @@ def network(self, name='default'): self.add_network(name) return NetworkFluentAPISelector(selector={'network': name}, study=self) - def add_link(self, network: str, src: str, dest: str, cost: int, quantity: Union[List[float], np.ndarray, float]): + def add_link(self, network: str, src: str, dest: str, cost: NumericalValueType, quantity: NumericalValueType): """ Add a link inside network. @@ -288,11 +254,11 @@ def add_link(self, network: str, src: str, dest: str, cost: int, quantity: Union if dest in [l.dest for l in self.networks[network].nodes[src].links]: raise ValueError('link destination must be unique on a node') - quantity = self._standardize_array(quantity) - if np.any(quantity < 0): + quantity = self.factory.create(quantity) + if quantity < 0: raise ValueError('Link quantity must be positive') - cost = self._standardize_array(cost) + cost = self.factory.create(cost) self.networks[network].nodes[src].links.append(Link(dest=dest, quantity=quantity, cost=cost)) return self @@ -309,31 +275,38 @@ def _add_production(self, network: str, node: str, prod: Production): if prod.name in [p.name for p in self.networks[network].nodes[node].productions]: raise ValueError('production name must be unique on a node') - prod.quantity = self._standardize_array(prod.quantity) - if np.any(prod.quantity < 0): + prod.quantity = self.factory.create(prod.quantity) + if prod.quantity < 0: raise ValueError('Production quantity must be positive') - prod.cost = self._standardize_array(prod.cost) + prod.cost = self.factory.create(prod.cost) self.networks[network].nodes[node].productions.append(prod) def _add_consumption(self, network: str, node: str, cons: Consumption): if cons.name in [c.name for c in self.networks[network].nodes[node].consumptions]: raise ValueError('consumption name must be unique on a node') - cons.quantity = self._standardize_array(cons.quantity) - if np.any(cons.quantity < 0): + cons.quantity = self.factory.create(cons.quantity) + if cons.quantity < 0: raise ValueError('Consumption quantity must be positive') - cons.cost = self._standardize_array(cons.cost) + cons.cost = self.factory.create(cons.cost) self.networks[network].nodes[node].consumptions.append(cons) def _add_storage(self, network: str, node: str, store: Storage): if store.name in [s.name for s in self.networks[network].nodes[node].storages]: raise ValueError('storage name must be unique on a node') + + store.flow_in = self.factory.create(store.flow_in) + store.flow_out = self.factory.create(store.flow_out) if store.flow_in < 0 or store.flow_out < 0: raise ValueError('storage flow must be positive') + + store.capacity = self.factory.create(store.capacity) if store.capacity < 0 or store.init_capacity < 0: raise ValueError('storage capacities must be positive') + + store.eff = self.factory.create(store.eff) if store.eff < 0 or store.eff > 1: raise ValueError('storage efficiency must be in ]0, 1[') @@ -344,13 +317,14 @@ def _add_converter(self, name: str): self.converters[name] = Converter(name=name, src_ratios={}, dest_network='', dest_node='', cost=0, max=0) - def _add_converter_src(self, name: str, network: str, node: str, ratio: float): + def _add_converter_src(self, name: str, network: str, node: str, ratio: NumericalValueType): if (network, node) in self.converters[name].src_ratios: raise ValueError('converter input already has node %s on network %s' % (node, network)) + ratio = self.factory.create(ratio) self.converters[name].src_ratios[(network, node)] = ratio - def _set_converter_dest(self, name: str, network: str, node: str, cost: float, max: float): + def _set_converter_dest(self, name: str, network: str, node: str, cost: NumericalValueType, max: NumericalValueType): if self.converters[name].dest_network and self.converters[name].dest_node: raise ValueError('converter has already output set') if network not in self.networks or node not in self.networks[network].nodes.keys(): @@ -358,35 +332,8 @@ def _set_converter_dest(self, name: str, network: str, node: str, cost: float, m self.converters[name].dest_network = network self.converters[name].dest_node = node - self.converters[name].cost = cost - self.converters[name].max = max - - def _standardize_array(self, array: Union[List[float], np.ndarray, float]) -> np.ndarray: - array = np.array(array, dtype=float) - - # If scenario and horizon are not provided, expend on both side - if array.size == 1: - return np.ones((self.nb_scn, self.horizon)) * array - - # If scenario are not provided copy timeseries for each scenario - if array.shape == (self.horizon,): - return np.tile(array, (self.nb_scn, 1)) - - # If horizon are not provide extend each scenario to full horizon - if array.shape == (self.nb_scn, 1): - return np.tile(array, self.horizon) - - # If perfect size - if array.shape == (self.nb_scn, self.horizon): - return array - - # If any size pattern matches, raise error on quantity size given - horizon_given = array.shape[0] if len(array.shape) == 1 else array.shape[1] - sc_given = 1 if len(array.shape) == 1 else array.shape[0] - raise ValueError('Array must be: a number, an array like (horizon, ) or (nb_scn, 1) or (nb_scn, horizon). ' - 'In your case horizon specified is %d and actual is %d. ' - 'And nb_scn specified %d is whereas actual is %d' % - (self.horizon, horizon_given, self.nb_scn, sc_given)) + self.converters[name].cost = self.factory.create(cost) + self.converters[name].max = self.factory.create(max) class NetworkFluentAPISelector: @@ -408,7 +355,7 @@ def node(self, name): self.study.add_node(network=self.selector['network'], node=name) return NodeFluentAPISelector(self.study, self.selector) - def link(self, src: str, dest: str, cost: int, quantity: Union[List, np.ndarray, float]): + def link(self, src: str, dest: str, cost: NumericalValueType, quantity: NumericalValueType): """ Add a link on network. @@ -432,7 +379,7 @@ def network(self, name='default'): self.study.add_network(name) return NetworkFluentAPISelector(selector={'network': name}, study=self.study) - def converter(self, name: str, to_network: str, to_node: str, max: float, cost: float = 0): + def converter(self, name: str, to_network: str, to_node: str, max: NumericalValueType, cost: NumericalValueType = 0): """ Add a converter element. @@ -464,7 +411,7 @@ def __init__(self, study, selector): self.study = study self.selector = selector - def consumption(self, name: str, cost: int, quantity: Union[List, np.ndarray, float]): + def consumption(self, name: str, cost: NumericalValueType, quantity: NumericalValueType): """ Add consumption on node. @@ -477,7 +424,7 @@ def consumption(self, name: str, cost: int, quantity: Union[List, np.ndarray, fl cons=Consumption(name=name, cost=cost, quantity=quantity)) return self - def production(self, name: str, cost: int, quantity: Union[List, np.ndarray, float]): + def production(self, name: str, cost: NumericalValueType, quantity: NumericalValueType): """ Add production on node. @@ -490,8 +437,8 @@ def production(self, name: str, cost: int, quantity: Union[List, np.ndarray, flo prod=Production(name=name, cost=cost, quantity=quantity)) return self - def storage(self, name, capacity: int, flow_in: float, flow_out: float, cost: float = 0, - init_capacity: int = 0, eff: int = 0.99): + def storage(self, name, capacity: NumericalValueType, flow_in: NumericalValueType, flow_out: NumericalValueType, + cost: NumericalValueType = 0, init_capacity: int = 0, eff: NumericalValueType = 0.99): """ Create storage. @@ -516,7 +463,7 @@ def node(self, name): """ return NetworkFluentAPISelector(self.study, self.selector).node(name) - def link(self, src: str, dest: str, cost: int, quantity: Union[List, np.ndarray, float]): + def link(self, src: str, dest: str, cost: int, quantity: NumericalValueType): """ Add a link on network. @@ -538,7 +485,7 @@ def network(self, name='default'): """ return NetworkFluentAPISelector(selector={}, study=self.study).network(name) - def converter(self, name: str, to_network: str, to_node: str, max: float, cost: float = 0): + def converter(self, name: str, to_network: str, to_node: str, max: NumericalValueType, cost: NumericalValueType = 0): """ Add a converter element. @@ -552,7 +499,7 @@ def converter(self, name: str, to_network: str, to_node: str, max: float, cost: return NetworkFluentAPISelector(selector={}, study=self.study)\ .converter(name=name, to_network=to_network, to_node=to_node, max=max, cost=cost) - def to_converter(self, name: str, ratio: float = 1): + def to_converter(self, name: str, ratio: NumericalValueType = 1): """ Add an ouptput to converter. diff --git a/hadar/optimizer/numeric.py b/hadar/optimizer/numeric.py index 5ef5116..81a86c8 100644 --- a/hadar/optimizer/numeric.py +++ b/hadar/optimizer/numeric.py @@ -9,7 +9,7 @@ from abc import ABC, abstractmethod from typing import TypeVar, Generic, Union, List -from hadar.optimizer.input import JSON +from hadar.optimizer.utils import JSON T = TypeVar('T') @@ -24,6 +24,10 @@ def __init__(self, value: T, horizon: int, nb_scn: int): def __getitem__(self, item) -> float: pass + @abstractmethod + def __lt__(self, other) -> bool: + pass + @abstractmethod def flatten(self) -> np.ndarray: pass @@ -38,6 +42,9 @@ def __getitem__(self, item) -> float: raise IndexError('There are %d time step you ask the %dth' % (self.horizon, j)) return self.value + def __lt__(self, other): + return self.value < other + def flatten(self) -> np.ndarray: return np.ones(self.horizon * self.nb_scn) * self.value @@ -46,7 +53,12 @@ def from_json(dict): return ScalarNumericalValue(**dict) -class MatrixNumericalValue(NumericalValue[np.ndarray]): +class NumpyNumericalValue(NumericalValue[np.ndarray], ABC): + def __lt__(self, other) -> bool: + return np.all(self.value < other) + + +class MatrixNumericalValue(NumpyNumericalValue): def __getitem__(self, item) -> float: i, j = item return self.value[i, j] @@ -60,7 +72,7 @@ def from_json(dict): MatrixNumericalValue(**dict) -class RowNumericValue(NumericalValue[np.ndarray]): +class RowNumericValue(NumpyNumericalValue): def __getitem__(self, item) -> float: i, j = item if i >= self.nb_scn: @@ -76,7 +88,7 @@ def from_json(dict): MatrixNumericalValue(**dict) -class ColumnNumericValue(NumericalValue[np.ndarray]): +class ColumnNumericValue(NumpyNumericalValue): def __getitem__(self, item) -> float: i, j = item if j >= self.horizon: @@ -98,7 +110,10 @@ def __init__(self, horizon: int, nb_scn: int): self.horizon = horizon self.nb_scn = nb_scn - def create(self, value: Union[float, List[float], str, np.ndarray]) -> NumericalValue: + def create(self, value: Union[float, List[float], str, np.ndarray, NumericalValue]) -> NumericalValue: + if isinstance(value, NumericalValue): + return value + if isinstance(value, int): return ScalarNumericalValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) diff --git a/hadar/optimizer/utils.py b/hadar/optimizer/utils.py new file mode 100644 index 0000000..16de193 --- /dev/null +++ b/hadar/optimizer/utils.py @@ -0,0 +1,48 @@ +# Copyright (c) 2019-2020, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Apache License, version 2.0. +# If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. +# SPDX-License-Identifier: Apache-2.0 +# This file is part of hadar-simulator, a python adequacy library for everyone. +from abc import ABC, abstractmethod + + +class DTO: + """ + Implement basic method for DTO objects + """ + def __hash__(self): + return hash(tuple(sorted(self.__dict__.items()))) + + def __eq__(self, other): + return isinstance(other, type(self)) and self.__dict__ == other.__dict__ + + def __str__(self): + return "{}({})".format(type(self).__name__, ", ".join(["{}={}".format(k, str(self.__dict__[k])) for k in sorted(self.__dict__)])) + + def __repr__(self): + return self.__str__() + + +class JSON(DTO, ABC): + """ + Object to be serializer by json + """ + def to_json(self): + def convert(value): + if isinstance(value, JSON): + return value.to_json() + elif isinstance(value, dict): + return {k: convert(v) for k, v in value.items()} + elif isinstance(value, list) or isinstance(value, tuple): + return [convert(v) for v in value] + elif isinstance(value, np.ndarray): + return value.tolist() + return value + + return {k: convert(v) for k, v in self.__dict__.items()} + + @staticmethod + @abstractmethod + def from_json(dict): + pass \ No newline at end of file diff --git a/hadar/workflow/pipeline.py b/hadar/workflow/pipeline.py index e7050ee..5f5c045 100644 --- a/hadar/workflow/pipeline.py +++ b/hadar/workflow/pipeline.py @@ -13,7 +13,7 @@ import pandas as pd from pandas import MultiIndex -from hadar.optimizer.input import DTO +from hadar.optimizer.utils import DTO __all__ = ['RestrictedPlug', 'FreePlug', 'Stage', 'FocusStage', 'Drop', 'Rename', 'Fault', 'RepeatScenario', 'ToShuffler', 'Pipeline', 'Clip'] diff --git a/tests/optimizer/test_input.py b/tests/optimizer/test_input.py index b3a43d7..b0f50bb 100644 --- a/tests/optimizer/test_input.py +++ b/tests/optimizer/test_input.py @@ -10,7 +10,8 @@ import numpy as np from hadar.optimizer.input import Study, Consumption, Production, Link, Storage, Converter -from utils import assert_result +from hadar.optimizer.numeric import NumericalValueFactory +from tests.utils import assert_result class TestStudy(unittest.TestCase): @@ -33,13 +34,16 @@ def setUp(self) -> None: .converter(name='converter', to_network='gas', to_node='b', cost=10, max=10) \ .build() + self.factory = NumericalValueFactory(horizon=self.study.horizon, nb_scn=self.study.nb_scn) + def test_create_study(self): - c = Consumption(name='load', cost=20, quantity=10) - p = Production(name='nuclear', cost=20, quantity=10) - s = Storage(name='store', capacity=100, flow_in=10, flow_out=10, cost=1, init_capacity=4, eff=0.1) - l = Link(dest='a', cost=20, quantity=10) - v = Converter(name='converter', src_ratios={('default', 'a'): 1}, dest_network='gas', - dest_node='b', cost=10, max=10) + c = Consumption(name='load', cost=self.factory.create(20), quantity=self.factory.create(10)) + p = Production(name='nuclear', cost=self.factory.create(20), quantity=self.factory.create(10)) + s = Storage(name='store', capacity=self.factory.create(100), flow_in=self.factory.create(10), + flow_out=self.factory.create(10), cost=self.factory.create(1), init_capacity=4, eff=self.factory.create(0.1)) + l = Link(dest='a', cost=self.factory.create(20), quantity=self.factory.create(10)) + v = Converter(name='converter', src_ratios={('default', 'a'): self.factory.create(1)}, dest_network='gas', + dest_node='b', cost=self.factory.create(10), max=self.factory.create(10)) self.assertEqual(c, self.study.networks['default'].nodes['a'].consumptions[0]) self.assertEqual(p, self.study.networks['default'].nodes['a'].productions[0]) diff --git a/tests/optimizer/test_numeric.py b/tests/optimizer/test_numeric.py index b7bdf9e..8ab522d 100644 --- a/tests/optimizer/test_numeric.py +++ b/tests/optimizer/test_numeric.py @@ -21,6 +21,8 @@ def test_scalar(self): self.assertEqual(42, v[2, 3]) self.assertRaises(IndexError, lambda: v[3, 1]) self.assertRaises(IndexError, lambda: v[1, 5]) + self.assertTrue(v < 50) + self.assertFalse(v < 30) np.testing.assert_array_equal([42] * 15, v.flatten()) def test_matrix(self): @@ -29,6 +31,8 @@ def test_matrix(self): self.assertEqual(13, v[2, 3]) self.assertRaises(IndexError, lambda: v[3, 1]) self.assertRaises(IndexError, lambda: v[1, 5]) + self.assertTrue(v < 16) + self.assertFalse(v < 10) np.testing.assert_array_equal(range(15), v.flatten()) def test_row(self): From cba2173189a1f968086d729add853594a36bf7dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Thu, 3 Sep 2020 15:35:37 +0200 Subject: [PATCH 05/38] WIP: #89 optimizer refactored with NumericalValue --- hadar/optimizer/input.py | 58 ++++++++++++++++-------- hadar/optimizer/lp/domain.py | 3 +- hadar/optimizer/lp/mapper.py | 21 +++++---- hadar/optimizer/numeric.py | 31 ++++++++++++- hadar/optimizer/utils.py | 29 ++++++------ tests/optimizer/lp/ortools_mock.py | 2 +- tests/optimizer/remote/test_optimizer.py | 4 +- tests/optimizer/test_input.py | 55 ---------------------- 8 files changed, 100 insertions(+), 103 deletions(-) diff --git a/hadar/optimizer/input.py b/hadar/optimizer/input.py index 8205f93..5565cb1 100644 --- a/hadar/optimizer/input.py +++ b/hadar/optimizer/input.py @@ -38,7 +38,9 @@ def __init__(self, quantity: NumericalValue, cost: NumericalValue, name: str = ' self.name = name @staticmethod - def from_json(dict): + def from_json(dict, factory=None): + dict['cost'] = factory.create(dict['cost']) + dict['quantity'] = factory.create(dict['quantity']) return Consumption(**dict) @@ -59,7 +61,9 @@ def __init__(self, quantity: NumericalValue, cost: NumericalValue, name: str = ' self.quantity = quantity @staticmethod - def from_json(dict): + def from_json(dict, factory=None): + dict['cost'] = factory.create(dict['cost']) + dict['quantity'] = factory.create(dict['quantity']) return Production(**dict) @@ -88,7 +92,13 @@ def __init__(self, name, capacity: NumericalValue, flow_in: NumericalValue, flow self.eff = eff @staticmethod - def from_json(dict): + def from_json(dict, factory=None): + dict['cost'] = factory.create(dict['cost']) + dict['capacity'] = factory.create(dict['capacity']) + dict['flow_in'] = factory.create(dict['flow_in']) + dict['flow_out'] = factory.create(dict['flow_out']) + dict['eff'] = factory.create(dict['eff']) + return Storage(**dict) @@ -109,7 +119,9 @@ def __init__(self, dest: str, quantity: NumericalValue, cost: NumericalValue): self.cost = cost @staticmethod - def from_json(dict): + def from_json(dict, factory=None): + dict['cost'] = factory.create(dict['cost']) + dict['quantity'] = factory.create(dict['quantity']) return Link(**dict) @@ -142,18 +154,19 @@ def to_json(self) -> dict: # src_ratios has a tuple of two string as key. These forbidden by JSON. # Therefore when serialized we join these two strings with '::' to create on string as key # Ex: ('elec', 'a') --> 'elec::a' - dict['src_ratios'] = {'::'.join(k): v for k, v in self.src_ratios.items()} - return dict + dict['src_ratios'] = {'::'.join(k): v.to_json() for k, v in self.src_ratios.items()} + return {k: JSON._convert(v) for k, v in dict.items()} @staticmethod - def from_json(dict: dict): + def from_json(dict: dict, factory=None): # When deserialize, we need to split key string of src_network. # JSON doesn't accept tuple as key, so two string was joined for serialization # Ex: 'elec::a' -> ('elec', 'a') - dict['src_ratios'] = {tuple(k.split('::')): v for k, v in dict['src_ratios'].items()} + dict['cost'] = factory.create(dict['cost']) + dict['max'] = factory.create(dict['max']) + dict['src_ratios'] = {tuple(k.split('::')): factory.create(v) for k, v in dict['src_ratios'].items()} return Converter(**dict) - class InputNode(JSON): """ Node element @@ -174,11 +187,11 @@ def __init__(self, consumptions: List[Consumption], productions: List[Production self.links = links @staticmethod - def from_json(dict): - dict['consumptions'] = [Consumption.from_json(v) for v in dict['consumptions']] - dict['productions'] = [Production.from_json(v) for v in dict['productions']] - dict['storages'] = [Storage.from_json(v) for v in dict['storages']] - dict['links'] = [Link.from_json(v) for v in dict['links']] + def from_json(dict, factory=None): + dict['consumptions'] = [Consumption.from_json(dict=v, factory=factory) for v in dict['consumptions']] + dict['productions'] = [Production.from_json(dict=v, factory=factory) for v in dict['productions']] + dict['storages'] = [Storage.from_json(dict=v, factory=factory) for v in dict['storages']] + dict['links'] = [Link.from_json(dict=v, factory=factory) for v in dict['links']] return InputNode(**dict) @@ -195,8 +208,8 @@ def __init__(self, nodes: Dict[str, InputNode] = None): self.nodes = nodes if nodes else {} @staticmethod - def from_json(dict): - dict['nodes'] = {k: InputNode.from_json(v) for k, v in dict['nodes'].items()} + def from_json(dict, factory=None): + dict['nodes'] = {k: InputNode.from_json(dict=v, factory=factory) for k, v in dict['nodes'].items()} return InputNetwork(**dict) @@ -219,12 +232,17 @@ def __init__(self, horizon: int, nb_scn: int = 1, version: str = None): self.nb_scn = nb_scn self.factory = NumericalValueFactory(horizon=horizon, nb_scn=nb_scn) + def to_json(self): + # remove factory from serialization + return {k: JSON._convert(v) for k, v in self.__dict__.items() if k not in ['factory']} + + @staticmethod - def from_json(dict): + def from_json(dict, factory=None): dict = deepcopy(dict) study = Study(horizon=dict['horizon'], nb_scn=dict['nb_scn'], version=dict['version']) - study.networks = {k: InputNetwork.from_json(v) for k, v in dict['networks'].items()} - study.converters = {k: Converter.from_json(v) for k, v in dict['converters'].items()} + study.networks = {k: InputNetwork.from_json(dict=v, factory=study.factory) for k, v in dict['networks'].items()} + study.converters = {k: Converter.from_json(dict=v, factory=study.factory) for k, v in dict['converters'].items()} return study def network(self, name='default'): @@ -310,6 +328,8 @@ def _add_storage(self, network: str, node: str, store: Storage): if store.eff < 0 or store.eff > 1: raise ValueError('storage efficiency must be in ]0, 1[') + store.cost = self.factory.create(store.cost) + self.networks[network].nodes[node].storages.append(store) def _add_converter(self, name: str): diff --git a/hadar/optimizer/lp/domain.py b/hadar/optimizer/lp/domain.py index 996539d..97ec63b 100644 --- a/hadar/optimizer/lp/domain.py +++ b/hadar/optimizer/lp/domain.py @@ -8,7 +8,8 @@ from ortools.linear_solver.pywraplp import Variable -from hadar.optimizer.input import DTO, Study +from hadar.optimizer.input import Study +from hadar.optimizer.utils import DTO class SerializableVariable(DTO): diff --git a/hadar/optimizer/lp/mapper.py b/hadar/optimizer/lp/mapper.py index 94bdb8e..2a1e6db 100644 --- a/hadar/optimizer/lp/mapper.py +++ b/hadar/optimizer/lp/mapper.py @@ -48,11 +48,11 @@ def get_node_var(self, network: str, node: str, t: int, scn: int) -> LPNode: variable=self.solver.NumVar(0, float(p.quantity[scn, t]), 'prod=%s %s' % (p.name, suffix))) for p in in_node.productions] - storages = [LPStorage(name=s.name, capacity=s.capacity, flow_in=s.flow_in, flow_out=s.flow_out, eff=s.eff, - init_capacity=s.init_capacity, cost=s.cost, - var_capacity=self.solver.NumVar(0, float(s.capacity), 'storage_capacity=%s %s' % (s.name, suffix)), - var_flow_in=self.solver.NumVar(0, float(s.flow_in), 'storage_flow_in=%s %s' % (s.name, suffix)), - var_flow_out=self.solver.NumVar(0, float(s.flow_out), 'storage_flow_out=%s %s' % (s.name, suffix))) + storages = [LPStorage(name=s.name, flow_in=s.flow_in[scn, t], flow_out=s.flow_out[scn, t], eff=s.eff[scn, t], + capacity=s.capacity[scn, t], init_capacity=s.init_capacity, cost=s.cost[scn, t], + var_capacity=self.solver.NumVar(0, float(s.capacity[scn, t]), 'storage_capacity=%s %s' % (s.name, suffix)), + var_flow_in=self.solver.NumVar(0, float(s.flow_in[scn, t]), 'storage_flow_in=%s %s' % (s.name, suffix)), + var_flow_out=self.solver.NumVar(0, float(s.flow_out[scn, t]), 'storage_flow_out=%s %s' % (s.name, suffix))) for s in in_node.storages] links = [LPLink(dest=l.dest, cost=l.cost[scn, t], src=node, quantity=l.quantity[scn, t], @@ -73,11 +73,12 @@ def get_conv_var(self, name: str, t: int, scn: int) -> LPConverter: suffix = 'at t=%d for scn=%d' % (t, scn) v = self.study.converters[name] - return LPConverter(name=v.name, src_ratios=v.src_ratios, dest_network=v.dest_network, dest_node=v.dest_node, - cost=v.cost, max=v.max, - var_flow_src={src: self.solver.NumVar(0, float(v.max / r), 'flow_src %s %s %s' % (v.name, ':'.join(src), suffix)) - for src, r in v.src_ratios.items()}, - var_flow_dest=self.solver.NumVar(0, float(v.max), 'flow_dest %s %s' % (v.name, suffix))) + src_ratios = {k: v[scn, t] for k, v in v.src_ratios.items()} + return LPConverter(name=v.name, src_ratios=src_ratios, dest_network=v.dest_network, dest_node=v.dest_node, + cost=v.cost[scn, t], max=v.max[scn, t], + var_flow_src={src: self.solver.NumVar(0, float(v.max[scn, t] / r), 'flow_src %s %s %s' % (v.name, ':'.join(src), suffix)) + for src, r in src_ratios.items()}, + var_flow_dest=self.solver.NumVar(0, float(v.max[scn, t]), 'flow_dest %s %s' % (v.name, suffix))) class OutputMapper: diff --git a/hadar/optimizer/numeric.py b/hadar/optimizer/numeric.py index 81a86c8..ae82d8f 100644 --- a/hadar/optimizer/numeric.py +++ b/hadar/optimizer/numeric.py @@ -9,7 +9,7 @@ from abc import ABC, abstractmethod from typing import TypeVar, Generic, Union, List -from hadar.optimizer.utils import JSON +from hadar.optimizer.utils import JSON, DTO T = TypeVar('T') @@ -28,6 +28,16 @@ def __getitem__(self, item) -> float: def __lt__(self, other) -> bool: pass + def __le__(self, other) -> bool: + return not self.__gt__(other) + + @abstractmethod + def __gt__(self, other) -> bool: + pass + + def __ge__(self, other) -> bool: + return not self.__lt__(other) + @abstractmethod def flatten(self) -> np.ndarray: pass @@ -45,6 +55,9 @@ def __getitem__(self, item) -> float: def __lt__(self, other): return self.value < other + def __gt__(self, other): + return self.value > other + def flatten(self) -> np.ndarray: return np.ones(self.horizon * self.nb_scn) * self.value @@ -57,6 +70,9 @@ class NumpyNumericalValue(NumericalValue[np.ndarray], ABC): def __lt__(self, other) -> bool: return np.all(self.value < other) + def __gt__(self, other) -> bool: + return np.all(self.value > other) + class MatrixNumericalValue(NumpyNumericalValue): def __getitem__(self, item) -> float: @@ -110,13 +126,24 @@ def __init__(self, horizon: int, nb_scn: int): self.horizon = horizon self.nb_scn = nb_scn + def __eq__(self, other): + if not isinstance(other, NumericalValueFactory): + return False + return other.horizon == self.horizon and other.nb_scn == self.nb_scn + def create(self, value: Union[float, List[float], str, np.ndarray, NumericalValue]) -> NumericalValue: if isinstance(value, NumericalValue): return value - if isinstance(value, int): + # If data come from json serialized dictionnary, use value key as input + if isinstance(value, dict) and 'value' in value: + value = value['value'] + + # If data is just a scalar + if isinstance(value, int) or isinstance(value, float): return ScalarNumericalValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) + # If data is list convert to numpy array if isinstance(value, List): value = np.array(value) diff --git a/hadar/optimizer/utils.py b/hadar/optimizer/utils.py index 16de193..8dcf724 100644 --- a/hadar/optimizer/utils.py +++ b/hadar/optimizer/utils.py @@ -5,6 +5,7 @@ # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. from abc import ABC, abstractmethod +import numpy as np class DTO: @@ -28,21 +29,23 @@ class JSON(DTO, ABC): """ Object to be serializer by json """ + + @staticmethod + def _convert(value): + if isinstance(value, JSON): + return value.to_json() + elif isinstance(value, dict): + return {k: JSON._convert(v) for k, v in value.items()} + elif isinstance(value, list) or isinstance(value, tuple): + return [JSON._convert(v) for v in value] + elif isinstance(value, np.ndarray): + return value.tolist() + return value + def to_json(self): - def convert(value): - if isinstance(value, JSON): - return value.to_json() - elif isinstance(value, dict): - return {k: convert(v) for k, v in value.items()} - elif isinstance(value, list) or isinstance(value, tuple): - return [convert(v) for v in value] - elif isinstance(value, np.ndarray): - return value.tolist() - return value - - return {k: convert(v) for k, v in self.__dict__.items()} + return {k: JSON._convert(v) for k, v in self.__dict__.items()} @staticmethod @abstractmethod - def from_json(dict): + def from_json(dict, factory=None): pass \ No newline at end of file diff --git a/tests/optimizer/lp/ortools_mock.py b/tests/optimizer/lp/ortools_mock.py index deff08e..49198ed 100644 --- a/tests/optimizer/lp/ortools_mock.py +++ b/tests/optimizer/lp/ortools_mock.py @@ -6,7 +6,7 @@ # This file is part of hadar-simulator, a python adequacy library for everyone. from ortools.linear_solver.pywraplp import Solver, Variable -from hadar.optimizer.input import DTO +from hadar.optimizer.utils import DTO class MockNumVar(DTO, Variable): diff --git a/tests/optimizer/remote/test_optimizer.py b/tests/optimizer/remote/test_optimizer.py index 3a77fef..8b80dd3 100644 --- a/tests/optimizer/remote/test_optimizer.py +++ b/tests/optimizer/remote/test_optimizer.py @@ -59,11 +59,11 @@ def setUp(self) -> None: def test_job_terminated(self): # Start server - httpd = HTTPServer(('localhost', 6964), MockSchedulerServer) + httpd = HTTPServer(('localhost', 6984), MockSchedulerServer) server = threading.Thread(None, handle_twice, None, (httpd.handle_request,)) server.start() - optim = RemoteOptimizer(url='http://localhost:6964') + optim = RemoteOptimizer(url='http://localhost:6984') res = optim.solve(self.study) self.assertEqual(self.result, res) diff --git a/tests/optimizer/test_input.py b/tests/optimizer/test_input.py index b0f50bb..e0fc5cb 100644 --- a/tests/optimizer/test_input.py +++ b/tests/optimizer/test_input.py @@ -195,61 +195,6 @@ def test(): self.assertRaises(ValueError, test) - def test_validate_quantity_perfect_size(self): - # Input - study = Study(horizon=10, nb_scn=2).network().build() - i = np.ones((2, 10)) - - # Test - r = study._standardize_array(i) - np.testing.assert_array_equal(i, r) - - def test_validate_quantity_expend_scn(self): - # Input - study = Study(horizon=5, nb_scn=2).network().build() - i = [1, 2, 3, 4, 5] - - # Expect - exp = np.array([[1, 2, 3, 4, 5], - [1, 2, 3, 4, 5]]) - - # Test - res = study._standardize_array(i) - np.testing.assert_array_equal(exp, res) - - def test_validate_quantity_expend_horizon(self): - # Input - study = Study(horizon=2, nb_scn=5).network().build() - i = [[1], [2], [3], [4], [5]] - - # Expect - exp = np.array([[1, 1], - [2, 2], - [3, 3], - [4, 4], - [5, 5]]) - - # Test - res = study._standardize_array(i) - np.testing.assert_array_equal(exp, res) - - def test_validate_quantity_expend_both(self): - # Input - study = Study(horizon=2, nb_scn=3).network().build() - i = 1 - - # Expect - exp = np.ones((3, 2)) - - # Test - res = study._standardize_array(i) - np.testing.assert_array_equal(exp, res) - - def test_validate_quantity_wrong_size(self): - # Input - study = Study( horizon=2).network().build() - self.assertRaises(ValueError, lambda: study._standardize_array([4, 5, 1])) - def test_serialization(self): d = self.study.to_json() j = json.dumps(d) From 18b68b2351684cb88f3eac59d9b74301f4a6be72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Thu, 3 Sep 2020 15:42:09 +0200 Subject: [PATCH 06/38] close #89 refactor analyzer with NumericalValue --- hadar/analyzer/result.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/hadar/analyzer/result.py b/hadar/analyzer/result.py index 2d97868..bc6ed34 100644 --- a/hadar/analyzer/result.py +++ b/hadar/analyzer/result.py @@ -279,15 +279,15 @@ def _build_storage(study: Study, result: Result): slices = stor.index[n_stor * h * scn: (n_stor + 1) * h * scn] study_stor = study.networks[n].nodes[node].storages[i] - stor.loc[slices, 'max_capacity'] = study_stor.capacity + stor.loc[slices, 'max_capacity'] = study_stor.capacity.flatten() stor.loc[slices, 'capacity'] = c.capacity.flatten() - stor.loc[slices, 'max_flow_in'] = study_stor.flow_in + stor.loc[slices, 'max_flow_in'] = study_stor.flow_in.flatten() stor.loc[slices, 'flow_in'] = c.flow_in.flatten() - stor.loc[slices, 'max_flow_out'] = study_stor.flow_out + stor.loc[slices, 'max_flow_out'] = study_stor.flow_out.flatten() stor.loc[slices, 'flow_out'] = c.flow_out.flatten() - stor.loc[slices, 'cost'] = study_stor.cost + stor.loc[slices, 'cost'] = study_stor.cost.flatten() stor.loc[slices, 'init_capacity'] = study_stor.init_capacity - stor.loc[slices, 'eff'] = study_stor.eff + stor.loc[slices, 'eff'] = study_stor.eff.flatten() stor.loc[slices, 'network'] = n stor.loc[slices, 'name'] = c.name stor.loc[slices, 'node'] = node @@ -348,8 +348,8 @@ def _build_dest_converter(study: Study, result: Result): for i, (name, v) in enumerate(study.converters.items()): slices = dest_conv.index[i * h * scn: (i + 1) * h * scn] dest_conv.loc[slices, 'name'] = v.name - dest_conv.loc[slices, 'cost'] = v.cost - dest_conv.loc[slices, 'max'] = v.max + dest_conv.loc[slices, 'cost'] = v.cost.flatten() + dest_conv.loc[slices, 'max'] = v.max.flatten() dest_conv.loc[slices, 'network'] = v.dest_network dest_conv.loc[slices, 'node'] = v.dest_node dest_conv.loc[slices, 'flow'] = result.converters[name].flow_dest.flatten() @@ -376,7 +376,6 @@ def _build_src_converter(study: Study, result: Result): e = s + h * scn * src_size slices = src_conv.index[s:e] src_conv.loc[slices, 'name'] = v.name - src_conv.loc[slices, 'max'] = v.max src_conv.loc[slices, 't'] = np.tile(np.arange(h), scn * src_size) src_conv.loc[slices, 'scn'] = np.repeat(np.arange(scn), h * src_size) @@ -385,7 +384,8 @@ def _build_src_converter(study: Study, result: Result): slices = src_conv.index[s:e] src_conv.loc[slices, 'network'] = net src_conv.loc[slices, 'node'] = node - src_conv.loc[slices, 'ratio'] = v.src_ratios[(net, node)] + src_conv.loc[slices, 'max'] = v.max.flatten() + src_conv.loc[slices, 'ratio'] = v.src_ratios[(net, node)].flatten() src_conv.loc[slices, 'flow'] = result.converters[name].flow_src[(net, node)].flatten() s = e s = e From bf3f893f2d2d83576a017ed5a761508b20951a6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Thu, 3 Sep 2020 17:51:09 +0200 Subject: [PATCH 07/38] #89 enable DataFrame and Series as NumericalValue sources --- hadar/optimizer/numeric.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/hadar/optimizer/numeric.py b/hadar/optimizer/numeric.py index ae82d8f..240927a 100644 --- a/hadar/optimizer/numeric.py +++ b/hadar/optimizer/numeric.py @@ -5,6 +5,7 @@ # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. import numpy as np +import pandas as pd from abc import ABC, abstractmethod from typing import TypeVar, Generic, Union, List @@ -135,16 +136,16 @@ def create(self, value: Union[float, List[float], str, np.ndarray, NumericalValu if isinstance(value, NumericalValue): return value - # If data come from json serialized dictionnary, use value key as input + # If data come from json serialized dictionary, use 'value' key as input if isinstance(value, dict) and 'value' in value: value = value['value'] # If data is just a scalar - if isinstance(value, int) or isinstance(value, float): + if type(value) in [float, int, complex]: return ScalarNumericalValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) - # If data is list convert to numpy array - if isinstance(value, List): + # If data is list or pandas object convert to numpy array + if type(value) in [List, list, pd.DataFrame, pd.Series]: value = np.array(value) if isinstance(value, np.ndarray): @@ -158,4 +159,14 @@ def create(self, value: Union[float, List[float], str, np.ndarray, NumericalValu # If perfect size if value.shape == (self.nb_scn, self.horizon): - return MatrixNumericalValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) \ No newline at end of file + return MatrixNumericalValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) + + # If any size pattern matches, raise error on quantity size given + horizon_given = value.shape[0] if len(value.shape) == 1 else value.shape[1] + sc_given = 1 if len(value.shape) == 1 else value.shape[0] + raise ValueError('Array must be: a number, an array like (horizon, ) or (nb_scn, 1) or (nb_scn, horizon). ' + 'In your case horizon specified is %d and actual is %d. ' + 'And nb_scn specified %d is whereas actual is %d' % + (self.horizon, horizon_given, self.nb_scn, sc_given)) + + raise ValueError('Wrong source data for numerical value') \ No newline at end of file From 180db1f85cf83931e013989e1d9f1a3549049d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Fri, 4 Sep 2020 15:51:48 +0200 Subject: [PATCH 08/38] Add comment in numeric.py. Refactor get_cost and get_elements to be more flexible with scope asked. Refactor Network Investment.ipynb to be more efficient. --- .../Network Investment.ipynb | 4 +- hadar/analyzer/result.py | 56 +++++++++++++------ hadar/optimizer/numeric.py | 35 +++++++++--- tests/analyzer/test_result.py | 16 +++--- tests/optimizer/lp/test_optimizer.py | 6 +- 5 files changed, 78 insertions(+), 39 deletions(-) diff --git a/examples/Network Investment/Network Investment.ipynb b/examples/Network Investment/Network Investment.ipynb index 175731d..051ddd3 100644 --- a/examples/Network Investment/Network Investment.ipynb +++ b/examples/Network Investment/Network Investment.ipynb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1410fd3904ebfb488b2e0b242d0a0ab233ff7fba34b80e4e0640d6a332ac7cde -size 8669491 +oid sha256:f9d1d8b275076f5b532ee4a67b59734ed19805e90dbc1b95d265e10867bcdb99 +size 8672681 diff --git a/hadar/analyzer/result.py b/hadar/analyzer/result.py index bc6ed34..c9c2b44 100644 --- a/hadar/analyzer/result.py +++ b/hadar/analyzer/result.py @@ -482,21 +482,40 @@ def network(self, name='default'): """ return NetworkFluentAPISelector([NetworkIndex(index=name)], self) - def get_elements_inside(self, node: str, network: str = 'default'): + def get_elements_inside(self, node: str = None, network: str = None): """ Get numbers of elements by node. - :param network: network name - :param node: node name + :param node: node name. None by default to ask whole network. + :param network: network name, 'default' as default if node is provided or None to ask whole network. :return: (nb of consumptions, nb of productions, nb of storages, nb of links (export), nb of converters (export), nb of converters (import) """ + size = np.zeros(6) + # Compute cost over study + if network is None and node is None: + for network in self.study.networks.keys(): + size += self.get_elements_inside(network=network) + return size + + # Compute cost over network + if network and node is None: + for node in self.study.networks[network].nodes.keys(): + size += self.get_elements_inside(network=network, node=node) + return size + + # If node is provided but no network set network as 'default' + if node and network is None: + network = 'default' + n = self.study.networks[network].nodes[node] - return len(n.consumptions), \ - len(n.productions), \ - len(n.storages), \ - len(n.links), \ - sum((network, node) in conv.src_ratios for conv in self.study.converters.values()), \ - sum((network == conv.dest_network) and (node == conv.dest_node) for conv in self.study.converters.values()) + return np.array([ + len(n.consumptions), + len(n.productions), + len(n.storages), + len(n.links), + sum((network, node) in conv.src_ratios for conv in self.study.converters.values()), + sum((network == conv.dest_network) and (node == conv.dest_node) for conv in self.study.converters.values()) + ]) def get_balance(self, node: str, network: str = 'default') -> np.ndarray: """ @@ -519,39 +538,40 @@ def get_balance(self, node: str, network: str = 'default') -> np.ndarray: balance += exp['used'].values.reshape(self.nb_scn, self.horizon) return balance - def get_cost(self, node: str, network: str = 'default') -> np.ndarray: + def get_cost(self, node: str = None, network: str = None) -> np.ndarray: """ - Compute adequacy cost on a node. + Compute adequacy cost on a node, network or whole study. - :param node: node name - :param network: network name, 'default' as default + :param node: node name. None by default to ask whole network. + :param network: network name, 'default' as default if node is provided or None to ask whole network. :return: matrix (scn, time) """ cost = np.zeros((self.nb_scn, self.horizon)) c, p, s, l, _, v = self.get_elements_inside(node, network) + network = 'default' if node and network is None else network if c: cons = self.network(network).node(node).scn().time().consumption() - cost += ((cons['asked'] - cons['given']) * cons['cost']).groupby(axis=0, level=(0, 1)) \ + cost += ((cons['asked'] - cons['given']) * cons['cost']).groupby(axis=0, level=('scn', 't')) \ .sum().sort_index(level=(0, 1)).values.reshape(self.nb_scn, self.horizon) if p: prod = self.network(network).node(node).scn().time().production() - cost += (prod['used'] * prod['cost']).groupby(axis=0, level=(0, 1)) \ + cost += (prod['used'] * prod['cost']).groupby(axis=0, level=('scn', 't')) \ .sum().sort_index(level=(0, 1)).values.reshape(self.nb_scn, self.horizon) if s: stor = self.network(network).node(node).scn().time().storage() - cost += (stor['capacity'] * stor['cost']).groupby(axis=0, level=(0, 1)) \ + cost += (stor['capacity'] * stor['cost']).groupby(axis=0, level=('scn', 't')) \ .sum().sort_index(level=(0, 1)).values.reshape(self.nb_scn, self.horizon) if l: link = self.network(network).node(node).scn().time().link() - cost += (link['used'] * link['cost']).groupby(axis=0, level=(0, 1)) \ + cost += (link['used'] * link['cost']).groupby(axis=0, level=('scn', 't')) \ .sum().sort_index(level=(0, 1)).values.reshape(self.nb_scn, self.horizon) if v: conv = self.network(network).node(node).scn().time().from_converter() - cost += (conv['flow'] * conv['cost']).groupby(axis=0, level=(0, 1)) \ + cost += (conv['flow'] * conv['cost']).groupby(axis=0, level=('scn', 't')) \ .sum().sort_index(level=(0, 1)).values.reshape(self.nb_scn, self.horizon) return cost diff --git a/hadar/optimizer/numeric.py b/hadar/optimizer/numeric.py index 240927a..da23c5c 100644 --- a/hadar/optimizer/numeric.py +++ b/hadar/optimizer/numeric.py @@ -10,12 +10,15 @@ from abc import ABC, abstractmethod from typing import TypeVar, Generic, Union, List -from hadar.optimizer.utils import JSON, DTO +from hadar.optimizer.utils import JSON T = TypeVar('T') class NumericalValue(JSON, ABC, Generic[T]): + """ + Interface to handle numerical value in study + """ def __init__(self, value: T, horizon: int, nb_scn: int): self.value = value self.horizon = horizon @@ -41,10 +44,17 @@ def __ge__(self, other) -> bool: @abstractmethod def flatten(self) -> np.ndarray: + """ + flat data into 1D matrix. + :return: [v[0, 0], v[0, 1], v[0, 2], ..., v[1, i], v[2, i], ..., v[j, i]) + """ pass class ScalarNumericalValue(NumericalValue[float]): + """ + Implement one scalar numerical value i.e. float or int + """ def __getitem__(self, item) -> float: i, j = item if i >= self.nb_scn: @@ -64,10 +74,13 @@ def flatten(self) -> np.ndarray: @staticmethod def from_json(dict): - return ScalarNumericalValue(**dict) + pass # not used. Deserialization is done by study elements themself class NumpyNumericalValue(NumericalValue[np.ndarray], ABC): + """ + Half-implementation with numpy array as numerical value. Implement only compare methods. + """ def __lt__(self, other) -> bool: return np.all(self.value < other) @@ -76,6 +89,9 @@ def __gt__(self, other) -> bool: class MatrixNumericalValue(NumpyNumericalValue): + """ + Implementation with complex matrix with shape (nb_scn, horizon) + """ def __getitem__(self, item) -> float: i, j = item return self.value[i, j] @@ -85,11 +101,13 @@ def flatten(self) -> np.ndarray: @staticmethod def from_json(dict): - dict['value'] = np.ndarray(dict['value']) - MatrixNumericalValue(**dict) + pass # not used. Deserialization is done by study elements themself class RowNumericValue(NumpyNumericalValue): + """ + Implementation with one scenario wiht shape (horizon, ). + """ def __getitem__(self, item) -> float: i, j = item if i >= self.nb_scn: @@ -101,11 +119,13 @@ def flatten(self) -> np.ndarray: @staticmethod def from_json(dict): - dict['value'] = np.ndarray(dict['value']) - MatrixNumericalValue(**dict) + pass # not used. Deserialization is done by study elements themself class ColumnNumericValue(NumpyNumericalValue): + """ + Implementation with one time step by scenario with shape (nb_scn, 1) + """ def __getitem__(self, item) -> float: i, j = item if j >= self.horizon: @@ -117,8 +137,7 @@ def flatten(self) -> np.ndarray: @staticmethod def from_json(dict): - dict['value'] = np.ndarray(dict['value']) - MatrixNumericalValue(**dict) + pass # not used. Deserialization is done by study elements themself class NumericalValueFactory: diff --git a/tests/analyzer/test_result.py b/tests/analyzer/test_result.py index 204c6d5..d5ecf8c 100644 --- a/tests/analyzer/test_result.py +++ b/tests/analyzer/test_result.py @@ -105,8 +105,8 @@ def test_aggregate_cons(self): def test_get_elements_inside(self): agg = ResultAnalyzer(study=self.study, result=self.result) - self.assertEqual((2, 0, 0, 0, 0, 0), agg.get_elements_inside('a')) - self.assertEqual((1, 0, 0, 0, 0, 0), agg.get_elements_inside('b')) + np.testing.assert_array_equal((2, 0, 0, 0, 0, 0), agg.get_elements_inside('a')) + np.testing.assert_array_equal((1, 0, 0, 0, 0, 0), agg.get_elements_inside('b')) class TestProductionAnalyzer(unittest.TestCase): @@ -163,8 +163,8 @@ def test_aggregate_prod(self): def test_get_elements_inside(self): agg = ResultAnalyzer(study=self.study, result=self.result) - self.assertEqual((0, 1, 0, 0, 0, 0), agg.get_elements_inside('a')) - self.assertEqual((0, 2, 0, 0, 0, 0), agg.get_elements_inside('b')) + np.testing.assert_array_equal((0, 1, 0, 0, 0, 0), agg.get_elements_inside('a')) + np.testing.assert_array_equal((0, 2, 0, 0, 0, 0), agg.get_elements_inside('b')) class TestStorageAnalyzer(unittest.TestCase): @@ -225,7 +225,7 @@ def test_aggregate_stor(self): def test_get_elements_inside(self): agg = ResultAnalyzer(study=self.study, result=self.result) - self.assertEqual((0, 0, 1, 0, 0, 0), agg.get_elements_inside('b')) + np.testing.assert_array_equal((0, 0, 1, 0, 0, 0), agg.get_elements_inside('b')) class TestLinkAnalyzer(unittest.TestCase): @@ -286,7 +286,7 @@ def test_balance(self): def test_get_elements_inside(self): agg = ResultAnalyzer(study=self.study, result=self.result) - self.assertEqual((0, 0, 0, 2, 0, 0), agg.get_elements_inside('a')) + np.testing.assert_array_equal((0, 0, 0, 2, 0, 0), agg.get_elements_inside('a')) class TestConverterAnalyzer(unittest.TestCase): @@ -361,8 +361,8 @@ def test_aggregate_from_conv(self): def test_get_elements_inside(self): agg = ResultAnalyzer(study=self.study, result=self.result) - self.assertEqual((0, 0, 0, 0, 1, 0), agg.get_elements_inside('a')) - self.assertEqual((0, 0, 0, 0, 0, 1), agg.get_elements_inside('a', network='elec')) + np.testing.assert_array_equal((0, 0, 0, 0, 1, 0), agg.get_elements_inside('a')) + np.testing.assert_array_equal((0, 0, 0, 0, 0, 1), agg.get_elements_inside('a', network='elec')) class TestAnalyzer(unittest.TestCase): diff --git a/tests/optimizer/lp/test_optimizer.py b/tests/optimizer/lp/test_optimizer.py index e85d6ec..e43c8fa 100644 --- a/tests/optimizer/lp/test_optimizer.py +++ b/tests/optimizer/lp/test_optimizer.py @@ -6,7 +6,7 @@ # This file is part of hadar-simulator, a python adequacy library for everyone. import pickle import unittest -from unittest.mock import MagicMock, call, ANY +from unittest.mock import MagicMock, call, ANY, Mock from hadar.optimizer.input import Study, Consumption from hadar.optimizer.lp.domain import LPConsumption, LPProduction, LPLink, LPNode, SerializableVariable, LPStorage, \ @@ -243,12 +243,12 @@ def test_solve_batch(self): def side_effect(network, node, t, scn): return var_node if network == 'default' and node == 'a' else empty_node in_mapper = InputMapper(solver=solver, study=study) - in_mapper.get_node_var = MagicMock(side_effect=side_effect) + in_mapper.get_node_var = Mock(side_effect=side_effect) exp_var_conv = LPConverter(name='conv', src_ratios={('default', 'a'): .5}, max=10, cost=1, var_flow_src={('default', 'a'): MockNumVar(0, 10, 'conv src')}, dest_network='gas', dest_node='b', var_flow_dest=MockNumVar(0, 10, 'conv dest')) - in_mapper.get_conv_var = MagicMock(return_value=exp_var_conv) + in_mapper.get_conv_var = Mock(return_value=exp_var_conv) # Expected in_cons = LPConsumption(name='load', quantity=10, cost=10, variable=SerializableVariable(MockNumVar(0, 10, 'load'))) From 5ba78286994015d529e3c737f0d28dcce6e31602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Mon, 7 Sep 2020 11:43:03 +0200 Subject: [PATCH 09/38] add domain package in solver for output.py input.py and numeric.py --- hadar/__init__.py | 3 +- hadar/analyzer/result.py | 4 +- hadar/optimizer/domain/__init__.py | 7 ++ hadar/optimizer/{ => domain}/input.py | 2 +- hadar/optimizer/{ => domain}/numeric.py | 0 hadar/optimizer/{ => domain}/output.py | 2 +- hadar/optimizer/lp/domain.py | 2 +- hadar/optimizer/lp/mapper.py | 4 +- hadar/optimizer/lp/optimizer.py | 4 +- hadar/optimizer/optimizer.py | 4 +- hadar/optimizer/remote/optimizer.py | 4 +- tests/analyzer/test_result.py | 4 +- tests/optimizer/it/test_optimizer.py | 142 +++++++++++------------ tests/optimizer/lp/test_mapper.py | 6 +- tests/optimizer/lp/test_optimizer.py | 4 +- tests/optimizer/remote/test_optimizer.py | 4 +- tests/optimizer/test_input.py | 7 +- tests/optimizer/test_numeric.py | 2 +- tests/optimizer/test_output.py | 2 +- tests/utils.py | 2 +- tests/viewer/test_html.py | 2 +- 21 files changed, 107 insertions(+), 104 deletions(-) create mode 100644 hadar/optimizer/domain/__init__.py rename hadar/optimizer/{ => domain}/input.py (99%) rename hadar/optimizer/{ => domain}/numeric.py (100%) rename hadar/optimizer/{ => domain}/output.py (99%) diff --git a/hadar/__init__.py b/hadar/__init__.py index cbefab6..f49883a 100644 --- a/hadar/__init__.py +++ b/hadar/__init__.py @@ -11,8 +11,7 @@ from .workflow.pipeline import RestrictedPlug, FreePlug, Stage, FocusStage, Drop, Rename, Fault, RepeatScenario, ToShuffler, Clip from .workflow.shuffler import Shuffler -from .optimizer.input import Consumption, Link, Production, InputNode, Study -from .optimizer.output import OutputProduction, OutputStorage, OutputNode, OutputLink, OutputConsumption, OutputNetwork, OutputConverter, Result +from .optimizer.domain.input import Study from .optimizer.optimizer import LPOptimizer, RemoteOptimizer from .viewer.html import HTMLPlotting from .analyzer.result import ResultAnalyzer diff --git a/hadar/analyzer/result.py b/hadar/analyzer/result.py index c9c2b44..c689437 100644 --- a/hadar/analyzer/result.py +++ b/hadar/analyzer/result.py @@ -11,8 +11,8 @@ import numpy as np import pandas as pd -from hadar.optimizer.input import Study -from hadar.optimizer.output import Result +from hadar.optimizer.domain.input import Study +from hadar.optimizer.domain.output import Result __all__ = ['ResultAnalyzer', 'NetworkFluentAPISelector'] diff --git a/hadar/optimizer/domain/__init__.py b/hadar/optimizer/domain/__init__.py new file mode 100644 index 0000000..84711aa --- /dev/null +++ b/hadar/optimizer/domain/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) 2019-2020, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Apache License, version 2.0. +# If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. +# SPDX-License-Identifier: Apache-2.0 +# This file is part of hadar-simulator, a python adequacy library for everyone. + diff --git a/hadar/optimizer/input.py b/hadar/optimizer/domain/input.py similarity index 99% rename from hadar/optimizer/input.py rename to hadar/optimizer/domain/input.py index 5565cb1..9513d4a 100644 --- a/hadar/optimizer/input.py +++ b/hadar/optimizer/domain/input.py @@ -14,7 +14,7 @@ 'NetworkFluentAPISelector', 'NodeFluentAPISelector'] import hadar -from hadar.optimizer.numeric import NumericalValue, NumericalValueFactory +from hadar.optimizer.domain.numeric import NumericalValue, NumericalValueFactory from hadar.optimizer.utils import JSON NumericalValueType: Type = Union[List, np.ndarray, float] diff --git a/hadar/optimizer/numeric.py b/hadar/optimizer/domain/numeric.py similarity index 100% rename from hadar/optimizer/numeric.py rename to hadar/optimizer/domain/numeric.py diff --git a/hadar/optimizer/output.py b/hadar/optimizer/domain/output.py similarity index 99% rename from hadar/optimizer/output.py rename to hadar/optimizer/domain/output.py index e26c332..7818869 100644 --- a/hadar/optimizer/output.py +++ b/hadar/optimizer/domain/output.py @@ -9,7 +9,7 @@ import numpy as np -from hadar.optimizer.input import InputNode, JSON +from hadar.optimizer.domain.input import InputNode, JSON __all__ = ['OutputProduction', 'OutputNode', 'OutputStorage', 'OutputLink', 'OutputConsumption', 'OutputNetwork', 'OutputConverter', 'Result'] diff --git a/hadar/optimizer/lp/domain.py b/hadar/optimizer/lp/domain.py index 97ec63b..aebe4f0 100644 --- a/hadar/optimizer/lp/domain.py +++ b/hadar/optimizer/lp/domain.py @@ -8,7 +8,7 @@ from ortools.linear_solver.pywraplp import Variable -from hadar.optimizer.input import Study +from hadar.optimizer.domain.input import Study from hadar.optimizer.utils import DTO diff --git a/hadar/optimizer/lp/mapper.py b/hadar/optimizer/lp/mapper.py index 2a1e6db..37a42dc 100644 --- a/hadar/optimizer/lp/mapper.py +++ b/hadar/optimizer/lp/mapper.py @@ -7,9 +7,9 @@ import numpy as np from ortools.linear_solver.pywraplp import Solver -from hadar.optimizer.input import Study, InputNetwork +from hadar.optimizer.domain.input import Study, InputNetwork from hadar.optimizer.lp.domain import LPLink, LPConsumption, LPNode, LPProduction, LPStorage, LPConverter -from hadar.optimizer.output import OutputNode, Result, OutputNetwork, OutputConverter +from hadar.optimizer.domain.output import OutputNode, Result, OutputNetwork, OutputConverter class InputMapper: diff --git a/hadar/optimizer/lp/optimizer.py b/hadar/optimizer/lp/optimizer.py index be0aa9d..0abafb1 100644 --- a/hadar/optimizer/lp/optimizer.py +++ b/hadar/optimizer/lp/optimizer.py @@ -12,10 +12,10 @@ from ortools.linear_solver.pywraplp import Solver, Constraint -from hadar.optimizer.input import Study +from hadar.optimizer.domain.input import Study from hadar.optimizer.lp.domain import LPNode, LPProduction, LPConsumption, LPLink, LPStorage, LPTimeStep, LPConverter from hadar.optimizer.lp.mapper import InputMapper, OutputMapper -from hadar.optimizer.output import Result +from hadar.optimizer.domain.output import Result logger = logging.getLogger(__name__) diff --git a/hadar/optimizer/optimizer.py b/hadar/optimizer/optimizer.py index 85ebe59..0fa5b30 100644 --- a/hadar/optimizer/optimizer.py +++ b/hadar/optimizer/optimizer.py @@ -7,9 +7,9 @@ from abc import ABC, abstractmethod -from hadar.optimizer.input import Study +from hadar.optimizer.domain.input import Study from hadar.optimizer.lp.optimizer import solve_lp -from hadar.optimizer.output import Result +from hadar.optimizer.domain.output import Result from hadar.optimizer.remote.optimizer import solve_remote __all__ = ['LPOptimizer', 'RemoteOptimizer'] diff --git a/hadar/optimizer/remote/optimizer.py b/hadar/optimizer/remote/optimizer.py index e07ec06..738458d 100644 --- a/hadar/optimizer/remote/optimizer.py +++ b/hadar/optimizer/remote/optimizer.py @@ -12,8 +12,8 @@ from progress.bar import Bar from progress.spinner import Spinner -from hadar.optimizer.input import Study -from hadar.optimizer.output import Result +from hadar.optimizer.domain.input import Study +from hadar.optimizer.domain.output import Result logger = logging.getLogger(__name__) diff --git a/tests/analyzer/test_result.py b/tests/analyzer/test_result.py index d5ecf8c..9843afb 100644 --- a/tests/analyzer/test_result.py +++ b/tests/analyzer/test_result.py @@ -12,8 +12,8 @@ from hadar import LPOptimizer from hadar.analyzer.result import Index, ResultAnalyzer, IntIndex -from hadar.optimizer.input import Production, Consumption, Study -from hadar.optimizer.output import OutputConsumption, OutputLink, OutputNode, OutputProduction, Result, OutputNetwork, \ +from hadar.optimizer.domain.input import Study +from hadar.optimizer.domain.output import OutputConsumption, OutputLink, OutputNode, OutputProduction, Result, OutputNetwork, \ OutputStorage, OutputConverter diff --git a/tests/optimizer/it/test_optimizer.py b/tests/optimizer/it/test_optimizer.py index 5dbb696..d68afc5 100644 --- a/tests/optimizer/it/test_optimizer.py +++ b/tests/optimizer/it/test_optimizer.py @@ -8,7 +8,7 @@ import unittest import hadar as hd -from hadar.optimizer.output import OutputNetwork +from hadar.optimizer.domain.output import OutputLink, OutputNode, OutputNetwork, OutputProduction, OutputConsumption, OutputStorage, OutputConverter, Result from tests.utils import assert_result @@ -45,17 +45,17 @@ def test_merit_order(self): .build() nodes_expected = dict() - nodes_expected['a'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[30, 6, 6], [6, 30, 30]], name='load')], + nodes_expected['a'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[30, 6, 6], [6, 30, 30]], name='load')], productions=[ - hd.OutputProduction(name='nuclear', quantity=[[15, 3, 3], [3, 15, 15]]), - hd.OutputProduction(name='solar', quantity=[[10, 2, 2], [2, 10, 10]]), - hd.OutputProduction(name='oil', quantity=[[5, 1, 1], [1, 5, 5]])], + OutputProduction(name='nuclear', quantity=[[15, 3, 3], [3, 15, 15]]), + OutputProduction(name='solar', quantity=[[10, 2, 2], [2, 10, 10]]), + OutputProduction(name='oil', quantity=[[5, 1, 1], [1, 5, 5]])], storages=[], links=[]) res = self.optimizer.solve(study) - assert_result(self, hd.Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) def test_exchange_two_nodes(self): """ @@ -85,20 +85,20 @@ def test_exchange_two_nodes(self): .build() nodes_expected = {} - nodes_expected['a'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[20, 200]], name='load')], - productions=[hd.OutputProduction(quantity=[[30, 300]], name='prod')], + nodes_expected['a'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[20, 200]], name='load')], + productions=[OutputProduction(quantity=[[30, 300]], name='prod')], storages=[], - links=[hd.OutputLink(dest='b', quantity=[[10, 100]])]) + links=[OutputLink(dest='b', quantity=[[10, 100]])]) - nodes_expected['b'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[20, 200]], name='load')], - productions=[hd.OutputProduction(quantity=[[10, 100]], name='prod')], + nodes_expected['b'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[20, 200]], name='load')], + productions=[OutputProduction(quantity=[[10, 100]], name='prod')], storages=[], links=[]) res = self.optimizer.solve(study) - assert_result(self, hd.Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) def test_exchange_two_concurrent_nodes(self): """ @@ -137,28 +137,28 @@ def test_exchange_two_concurrent_nodes(self): .build() nodes_expected = {} - nodes_expected['a'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], - productions=[hd.OutputProduction(quantity=[[30]], name='nuclear')], + nodes_expected['a'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name='load')], + productions=[OutputProduction(quantity=[[30]], name='nuclear')], storages=[], - links=[hd.OutputLink(dest='b', quantity=[[10]]), - hd.OutputLink(dest='c', quantity=[[10]])]) + links=[OutputLink(dest='b', quantity=[[10]]), + OutputLink(dest='c', quantity=[[10]])]) - nodes_expected['b'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], - productions=[hd.OutputProduction(quantity=[[0]], name='nuclear')], + nodes_expected['b'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name='load')], + productions=[OutputProduction(quantity=[[0]], name='nuclear')], storages=[], links=[]) - nodes_expected['c'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], - productions=[hd.OutputProduction(quantity=[[0]], name='nuclear')], + nodes_expected['c'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name='load')], + productions=[OutputProduction(quantity=[[0]], name='nuclear')], storages=[], links=[]) res = self.optimizer.solve(study) - assert_result(self, hd.Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) def test_exchange_link_saturation(self): """ @@ -183,25 +183,25 @@ def test_exchange_link_saturation(self): .build() nodes_expected = {} - nodes_expected['a'] = hd.OutputNode(productions=[hd.OutputProduction(quantity=[[20]], name='nuclear')], - links=[hd.OutputLink(dest='b', quantity=[[20]])], + nodes_expected['a'] = OutputNode(productions=[OutputProduction(quantity=[[20]], name='nuclear')], + links=[OutputLink(dest='b', quantity=[[20]])], storages=[], consumptions=[]) - nodes_expected['b'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], - links=[hd.OutputLink(dest='c', quantity=[[10]])], + nodes_expected['b'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name='load')], + links=[OutputLink(dest='c', quantity=[[10]])], storages=[], productions=[]) - nodes_expected['c'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], + nodes_expected['c'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name='load')], productions=[], storages=[], links=[]) res = self.optimizer.solve(study) - assert_result(self, hd.Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) def test_consumer_cancel_exchange(self): """ @@ -235,27 +235,27 @@ def test_consumer_cancel_exchange(self): .build() nodes_expected = {} - nodes_expected['a'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], - productions=[hd.OutputProduction(quantity=[[20]], name='nuclear')], + nodes_expected['a'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name='load')], + productions=[OutputProduction(quantity=[[20]], name='nuclear')], storages=[], - links=[hd.OutputLink(dest='b', quantity=[[10]])]) + links=[OutputLink(dest='b', quantity=[[10]])]) - nodes_expected['b'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[5]], name='load')], - productions=[hd.OutputProduction(quantity=[[5]], name='nuclear')], + nodes_expected['b'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[5]], name='load')], + productions=[OutputProduction(quantity=[[5]], name='nuclear')], storages=[], - links=[hd.OutputLink(dest='c', quantity=[[10]])]) + links=[OutputLink(dest='c', quantity=[[10]])]) - nodes_expected['c'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[20]], name='load')], - productions=[hd.OutputProduction(quantity=[[10]], name='nuclear')], + nodes_expected['c'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[20]], name='load')], + productions=[OutputProduction(quantity=[[10]], name='nuclear')], storages=[], links=[]) res = self.optimizer.solve(study) - assert_result(self, hd.Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) def test_many_links_on_node(self): @@ -301,22 +301,22 @@ def test_many_links_on_node(self): nodes_expected = {} - nodes_expected['a'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[10, 10]], name='load')], - productions=[hd.OutputProduction(quantity=[[0, 5]], name='gas')], - storages=[], links=[hd.OutputLink(dest='b', quantity=[[0, 10]])]) + nodes_expected['a'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10, 10]], name='load')], + productions=[OutputProduction(quantity=[[0, 5]], name='gas')], + storages=[], links=[OutputLink(dest='b', quantity=[[0, 10]])]) - nodes_expected['b'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[15, 25]], name='load')], + nodes_expected['b'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[15, 25]], name='load')], storages=[], productions=[], links=[]) - nodes_expected['c'] = hd.OutputNode( - productions=[hd.OutputProduction(quantity=[[25, 30]], name='nuclear')], + nodes_expected['c'] = OutputNode( + productions=[OutputProduction(quantity=[[25, 30]], name='nuclear')], storages=[], links=[], consumptions=[]) res = self.optimizer.solve(study) - assert_result(self, hd.Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) def test_storage(self): """ @@ -339,19 +339,19 @@ def test_storage(self): .build() nodes_expected = dict() - nodes_expected['a'] = hd.OutputNode( - productions=[hd.OutputProduction(quantity=[[10, 10, 10, 0]], name='nuclear')], - storages=[], consumptions=[], links=[hd.OutputLink(dest='b', quantity=[[10, 10, 10, 0]])]) + nodes_expected['a'] = OutputNode( + productions=[OutputProduction(quantity=[[10, 10, 10, 0]], name='nuclear')], + storages=[], consumptions=[], links=[OutputLink(dest='b', quantity=[[10, 10, 10, 0]])]) - nodes_expected['b'] = hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[20, 10, 0, 10]], name='load')], - storages=[hd.OutputStorage(name='cell', capacity=[[5, 5, 10, 0]], + nodes_expected['b'] = OutputNode( + consumptions=[OutputConsumption(quantity=[[20, 10, 0, 10]], name='load')], + storages=[OutputStorage(name='cell', capacity=[[5, 5, 10, 0]], flow_in=[[0, 0, 10, 0]], flow_out=[[10, 0, 0, 10]])], productions=[], links=[]) res = self.optimizer.solve(study) - assert_result(self, hd.Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) def test_multi_energies(self): study = hd.Study(horizon=1)\ @@ -370,20 +370,20 @@ def test_multi_energies(self): .build() networks_expected = dict() - networks_expected['elec'] = hd.OutputNetwork(nodes={'a': hd.OutputNode( - consumptions=[hd.OutputConsumption(quantity=[[10]], name='load')], + networks_expected['elec'] = OutputNetwork(nodes={'a': OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name='load')], storages=[], productions=[], links=[])}) - networks_expected['gas'] = hd.OutputNetwork(nodes={'b': hd.OutputNode( - productions=[hd.OutputProduction(quantity=[[12.5]], name='central')], + networks_expected['gas'] = OutputNetwork(nodes={'b': OutputNode( + productions=[OutputProduction(quantity=[[12.5]], name='central')], storages=[], consumptions=[], links=[])}) - networks_expected['coat'] = hd.OutputNetwork(nodes={'c': hd.OutputNode( - productions=[hd.OutputProduction(quantity=[[20]], name='central')], + networks_expected['coat'] = OutputNetwork(nodes={'c': OutputNode( + productions=[OutputProduction(quantity=[[20]], name='central')], storages=[], consumptions=[], links=[])}) - converter_expected = hd.OutputConverter(name='conv', flow_src={('gas', 'b'): [[12.5]], ('coat', 'c'): [[20]]}, flow_dest=[[10]]) + converter_expected = OutputConverter(name='conv', flow_src={('gas', 'b'): [[12.5]], ('coat', 'c'): [[20]]}, flow_dest=[[10]]) res = self.optimizer.solve(study) - assert_result(self, hd.Result(networks=networks_expected, converters={'conv': converter_expected}), res) \ No newline at end of file + assert_result(self, Result(networks=networks_expected, converters={'conv': converter_expected}), res) \ No newline at end of file diff --git a/tests/optimizer/lp/test_mapper.py b/tests/optimizer/lp/test_mapper.py index cbe2852..e9394b7 100644 --- a/tests/optimizer/lp/test_mapper.py +++ b/tests/optimizer/lp/test_mapper.py @@ -7,10 +7,10 @@ import unittest -from hadar.optimizer.input import Production, Consumption, Study -from hadar.optimizer.lp.domain import LPLink, LPConsumption, LPProduction, LPNode, LPStorage, LPConverter, LPNetwork +from hadar.optimizer.domain.input import Study +from hadar.optimizer.lp.domain import LPLink, LPConsumption, LPProduction, LPNode, LPStorage, LPConverter from hadar.optimizer.lp.mapper import InputMapper, OutputMapper -from hadar.optimizer.output import OutputConsumption, OutputLink, OutputNode, OutputProduction, Result, OutputNetwork, \ +from hadar.optimizer.domain.output import OutputConsumption, OutputLink, OutputNode, OutputProduction, Result, OutputNetwork, \ OutputStorage, OutputConverter from tests.optimizer.lp.ortools_mock import MockSolver, MockNumVar from tests.utils import assert_result diff --git a/tests/optimizer/lp/test_optimizer.py b/tests/optimizer/lp/test_optimizer.py index e43c8fa..bc5edac 100644 --- a/tests/optimizer/lp/test_optimizer.py +++ b/tests/optimizer/lp/test_optimizer.py @@ -8,14 +8,14 @@ import unittest from unittest.mock import MagicMock, call, ANY, Mock -from hadar.optimizer.input import Study, Consumption +from hadar.optimizer.domain.input import Study from hadar.optimizer.lp.domain import LPConsumption, LPProduction, LPLink, LPNode, SerializableVariable, LPStorage, \ LPConverter, LPTimeStep, LPNetwork from hadar.optimizer.lp.mapper import InputMapper, OutputMapper from hadar.optimizer.lp.optimizer import ObjectiveBuilder, AdequacyBuilder, _solve_batch, StorageBuilder, \ ConverterMixBuilder from hadar.optimizer.lp.optimizer import solve_lp -from hadar.optimizer.output import OutputConsumption, OutputNode, Result, OutputNetwork, OutputConverter +from hadar.optimizer.domain.output import OutputConsumption, OutputNode, Result, OutputNetwork, OutputConverter from tests.optimizer.lp.ortools_mock import MockConstraint, MockNumVar, MockObjective, MockSolver diff --git a/tests/optimizer/remote/test_optimizer.py b/tests/optimizer/remote/test_optimizer.py index 8b80dd3..3394081 100644 --- a/tests/optimizer/remote/test_optimizer.py +++ b/tests/optimizer/remote/test_optimizer.py @@ -10,8 +10,8 @@ from http.server import BaseHTTPRequestHandler, HTTPServer from hadar import RemoteOptimizer -from hadar.optimizer.input import Study -from hadar.optimizer.output import Result, OutputConsumption, OutputNode, OutputNetwork +from hadar.optimizer.domain.input import Study +from hadar.optimizer.domain.output import Result, OutputConsumption, OutputNode, OutputNetwork from hadar.optimizer.remote.optimizer import check_code diff --git a/tests/optimizer/test_input.py b/tests/optimizer/test_input.py index e0fc5cb..6062f78 100644 --- a/tests/optimizer/test_input.py +++ b/tests/optimizer/test_input.py @@ -7,11 +7,8 @@ import json import unittest -import numpy as np - -from hadar.optimizer.input import Study, Consumption, Production, Link, Storage, Converter -from hadar.optimizer.numeric import NumericalValueFactory -from tests.utils import assert_result +from hadar.optimizer.domain.input import Study, Consumption, Production, Link, Storage, Converter +from hadar.optimizer.domain.numeric import NumericalValueFactory class TestStudy(unittest.TestCase): diff --git a/tests/optimizer/test_numeric.py b/tests/optimizer/test_numeric.py index 8ab522d..f7c7e45 100644 --- a/tests/optimizer/test_numeric.py +++ b/tests/optimizer/test_numeric.py @@ -8,7 +8,7 @@ import unittest import numpy as np -from hadar.optimizer.numeric import NumericalValueFactory, ScalarNumericalValue, MatrixNumericalValue, RowNumericValue, ColumnNumericValue +from hadar.optimizer.domain.numeric import NumericalValueFactory, ScalarNumericalValue, MatrixNumericalValue, RowNumericValue, ColumnNumericValue class TestNumericalValue(unittest.TestCase): diff --git a/tests/optimizer/test_output.py b/tests/optimizer/test_output.py index 544d599..b93316b 100644 --- a/tests/optimizer/test_output.py +++ b/tests/optimizer/test_output.py @@ -7,7 +7,7 @@ import json import unittest -from hadar.optimizer.output import * +from hadar.optimizer.domain.output import * class TestResult(unittest.TestCase): diff --git a/tests/utils.py b/tests/utils.py index b3c80e6..d8fbd12 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -7,7 +7,7 @@ import numpy as np -from hadar.optimizer.output import Result +from hadar.optimizer.domain.output import Result def assert_result(self, expected: Result, result: Result): diff --git a/tests/viewer/test_html.py b/tests/viewer/test_html.py index 92cc667..6385a66 100644 --- a/tests/viewer/test_html.py +++ b/tests/viewer/test_html.py @@ -11,7 +11,7 @@ from plotly.offline.offline import plot from hadar.analyzer.result import ResultAnalyzer -from hadar.optimizer.input import Study, Production, Consumption +from hadar.optimizer.domain.input import Study from hadar.optimizer.optimizer import LPOptimizer from hadar.viewer.html import HTMLPlotting From e6014261cae83f2d317bbaf4bd58b4e273f85cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Mon, 7 Sep 2020 14:12:06 +0200 Subject: [PATCH 10/38] Compute benchmark and add in result object --- hadar/optimizer/domain/output.py | 36 ++++++++++++++------ hadar/optimizer/lp/optimizer.py | 31 +++++++++++++---- tests/optimizer/domain/__init__.py | 7 ++++ tests/optimizer/{ => domain}/test_input.py | 0 tests/optimizer/{ => domain}/test_numeric.py | 0 tests/optimizer/{ => domain}/test_output.py | 0 tests/optimizer/it/test_optimizer.py | 1 + 7 files changed, 58 insertions(+), 17 deletions(-) create mode 100644 tests/optimizer/domain/__init__.py rename tests/optimizer/{ => domain}/test_input.py (100%) rename tests/optimizer/{ => domain}/test_numeric.py (100%) rename tests/optimizer/{ => domain}/test_output.py (100%) diff --git a/hadar/optimizer/domain/output.py b/hadar/optimizer/domain/output.py index 7818869..14a561f 100644 --- a/hadar/optimizer/domain/output.py +++ b/hadar/optimizer/domain/output.py @@ -31,7 +31,7 @@ def __init__(self, quantity: Union[np.ndarray, list], name: str = ''): @staticmethod - def from_json(dict): + def from_json(dict, factory=None): return OutputConsumption(**dict) @@ -50,7 +50,7 @@ def __init__(self, quantity: Union[np.ndarray, list], name: str = 'in'): self.quantity = np.array(quantity) @staticmethod - def from_json(dict): + def from_json(dict, factory=None): return OutputProduction(**dict) @@ -74,7 +74,7 @@ def __init__(self, name: str, capacity: Union[np.ndarray, list], self.flow_out = np.array(flow_out) @staticmethod - def from_json(dict): + def from_json(dict, factory=None): return OutputStorage(**dict) @@ -93,7 +93,7 @@ def __init__(self, dest: str, quantity: Union[np.ndarray, list]): self.quantity = np.array(quantity) @staticmethod - def from_json(dict): + def from_json(dict, factory=None): return OutputLink(**dict) @@ -123,7 +123,7 @@ def to_json(self) -> dict: return dict @staticmethod - def from_json(dict: dict): + def from_json(dict: dict, factory=None): # When deserialize, we need to split key string of src_network. # JSON doesn't accept tuple as key, so two string was joined for serialization # Ex: 'elec::a' -> ('elec', 'a') @@ -171,7 +171,7 @@ def build_like_input(input: InputNode, fill: np.ndarray): return output @staticmethod - def from_json(dict): + def from_json(dict, factory=None): dict['consumptions'] = [OutputConsumption.from_json(v) for v in dict['consumptions']] dict['productions'] = [OutputProduction.from_json(v) for v in dict['productions']] dict['storages'] = [OutputStorage.from_json(v) for v in dict['storages']] @@ -192,25 +192,41 @@ def __init__(self, nodes: Dict[str, OutputNode]): self.nodes = nodes @staticmethod - def from_json(dict): + def from_json(dict, factory=None): dict['nodes'] = {k: OutputNode.from_json(v) for k, v in dict['nodes'].items()} return OutputNetwork(**dict) +class Benchmark(JSON): + def __init__(self, modeler: List[int] = None, solver: List[int] = None, mapper: int = 0, total: int = 0): + self.modeler = modeler or [] + self.solver = solver or [] + self.mapper = mapper + self.total = total + + @staticmethod + def from_json(dict, factory=None): + return Benchmark(**dict) + + class Result(JSON): """ Result of study """ - def __init__(self, networks: Dict[str, OutputNetwork], converters: Dict[str, OutputConverter]): + def __init__(self, networks: Dict[str, OutputNetwork], + converters: Dict[str, OutputConverter], + benchmark: Benchmark = None): """ Create result :param networks: list of networks present in study """ self.networks = networks self.converters = converters + self.benchmark = benchmark or Benchmark() @staticmethod - def from_json(dict): + def from_json(dict, factory=None): return Result(networks={k: OutputNetwork.from_json(v) for k, v in dict['networks'].items()}, - converters={k: OutputConverter.from_json(v) for k, v in dict['converters'].items()}) + converters={k: OutputConverter.from_json(v) for k, v in dict['converters'].items()}, + benchmark=Benchmark.from_json(dict['benchmark'])) diff --git a/hadar/optimizer/lp/optimizer.py b/hadar/optimizer/lp/optimizer.py index 0abafb1..2747d24 100644 --- a/hadar/optimizer/lp/optimizer.py +++ b/hadar/optimizer/lp/optimizer.py @@ -8,6 +8,7 @@ import logging import multiprocessing import pickle +import time from typing import List from ortools.linear_solver.pywraplp import Solver, Constraint @@ -15,7 +16,7 @@ from hadar.optimizer.domain.input import Study from hadar.optimizer.lp.domain import LPNode, LPProduction, LPConsumption, LPLink, LPStorage, LPTimeStep, LPConverter from hadar.optimizer.lp.mapper import InputMapper, OutputMapper -from hadar.optimizer.domain.output import Result +from hadar.optimizer.domain.output import Result, Benchmark logger = logging.getLogger(__name__) @@ -284,6 +285,7 @@ def _solve_batch(params) -> bytes: , mock adequacy, mock input mapper) only for test purpose. :return: [t: {name: LPNode, ...}, ...] """ + start = time.time() if len(params) == 2: # Runtime study, i_scn = params @@ -325,17 +327,20 @@ def _solve_batch(params) -> bytes: storage.build() mix.build() + problem_build = time.time() + logger.info('Problem build. Start solver') solver.EnableOutput() solver.Solve() + problem_solved = time.time() logger.info('Solver finish cost=%d', solver.Objective().Value()) logger.debug(solver.ExportModelAsLpFormat(False).replace('\\', '').replace(',_', ',')) # When multiprocessing handle response and serialize it with pickle, # it's occur that ortools variables seem already erased. # To fix this situation, serialization is handle inside 'job scope' - return pickle.dumps(variables) + return pickle.dumps((variables, problem_build - start, problem_solved - start)) def solve_lp(study: Study, out_mapper=None) -> Result: @@ -346,21 +351,33 @@ def solve_lp(study: Study, out_mapper=None) -> Result: :param out_mapper: use only for test purpose to inject mock. Keep None as default. :return: Result object with optimal solution """ + start = time.time() + benchmark = Benchmark() + out_mapper = out_mapper or OutputMapper(study) pool = multiprocessing.Pool() - byte = pool.map(_solve_batch, ((study, i_scn) for i_scn in range(study.nb_scn))) - variables = [pickle.loads(b) for b in byte] + serialized_out = pool.map(_solve_batch, ((study, i_scn) for i_scn in range(study.nb_scn))) + compute_finished = time.time() for scn in range(0, study.nb_scn): + variables, modeler, solver = pickle.loads(serialized_out[scn]) + benchmark.modeler.append(modeler) + benchmark.solver.append(solver) + for t in range(0, study.horizon): # Set node elements for name_network, network in study.networks.items(): for name_node in network.nodes.keys(): out_mapper.set_node_var(network=name_network, node=name_node, t=t, scn=scn, - vars=variables[scn][t].networks[name_network].nodes[name_node]) + vars=variables[t].networks[name_network].nodes[name_node]) # Set converters for name_conv in study.converters: - out_mapper.set_converter_var(name=name_conv, t=t, scn=scn, vars=variables[scn][t].converters[name_conv]) + out_mapper.set_converter_var(name=name_conv, t=t, scn=scn, vars=variables[t].converters[name_conv]) + + benchmark.total = time.time() - start + benchmark.mapper = time.time() - compute_finished - return out_mapper.get_result() + res = out_mapper.get_result() + res.benchmark = benchmark + return res diff --git a/tests/optimizer/domain/__init__.py b/tests/optimizer/domain/__init__.py new file mode 100644 index 0000000..84711aa --- /dev/null +++ b/tests/optimizer/domain/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) 2019-2020, RTE (https://www.rte-france.com) +# See AUTHORS.txt +# This Source Code Form is subject to the terms of the Apache License, version 2.0. +# If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. +# SPDX-License-Identifier: Apache-2.0 +# This file is part of hadar-simulator, a python adequacy library for everyone. + diff --git a/tests/optimizer/test_input.py b/tests/optimizer/domain/test_input.py similarity index 100% rename from tests/optimizer/test_input.py rename to tests/optimizer/domain/test_input.py diff --git a/tests/optimizer/test_numeric.py b/tests/optimizer/domain/test_numeric.py similarity index 100% rename from tests/optimizer/test_numeric.py rename to tests/optimizer/domain/test_numeric.py diff --git a/tests/optimizer/test_output.py b/tests/optimizer/domain/test_output.py similarity index 100% rename from tests/optimizer/test_output.py rename to tests/optimizer/domain/test_output.py diff --git a/tests/optimizer/it/test_optimizer.py b/tests/optimizer/it/test_optimizer.py index d68afc5..d79b1d9 100644 --- a/tests/optimizer/it/test_optimizer.py +++ b/tests/optimizer/it/test_optimizer.py @@ -385,5 +385,6 @@ def test_multi_energies(self): converter_expected = OutputConverter(name='conv', flow_src={('gas', 'b'): [[12.5]], ('coat', 'c'): [[20]]}, flow_dest=[[10]]) res = self.optimizer.solve(study) + print(res.benchmark) assert_result(self, Result(networks=networks_expected, converters={'conv': converter_expected}), res) \ No newline at end of file From c52d7dc4feedd3904af02c63d727a3a69853bc70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Mon, 7 Sep 2020 14:19:26 +0200 Subject: [PATCH 11/38] fix ut --- tests/optimizer/it/test_optimizer.py | 1 - tests/optimizer/lp/test_optimizer.py | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/optimizer/it/test_optimizer.py b/tests/optimizer/it/test_optimizer.py index d79b1d9..d68afc5 100644 --- a/tests/optimizer/it/test_optimizer.py +++ b/tests/optimizer/it/test_optimizer.py @@ -385,6 +385,5 @@ def test_multi_energies(self): converter_expected = OutputConverter(name='conv', flow_src={('gas', 'b'): [[12.5]], ('coat', 'c'): [[20]]}, flow_dest=[[10]]) res = self.optimizer.solve(study) - print(res.benchmark) assert_result(self, Result(networks=networks_expected, converters={'conv': converter_expected}), res) \ No newline at end of file diff --git a/tests/optimizer/lp/test_optimizer.py b/tests/optimizer/lp/test_optimizer.py index bc5edac..0109aa4 100644 --- a/tests/optimizer/lp/test_optimizer.py +++ b/tests/optimizer/lp/test_optimizer.py @@ -264,8 +264,10 @@ def side_effect(network, node, t, scn): # Test res = _solve_batch((study, 0, solver, objective, adequacy, storage, mix, in_mapper)) - res = pickle.loads(res) + res, t_mod, t_sol = pickle.loads(res) self.assertEqual([expected], res) + self.assertTrue(t_mod > 0) + self.assertTrue(t_sol > 0) in_mapper.get_node_var.assert_has_calls([call(network='default', node='a', t=0, scn=0), call(network='gas', node='b', t=0, scn=0)]) From b5a76bb65596cc2a01a4f1130db3aae3d02059cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Tue, 8 Sep 2020 11:08:13 +0200 Subject: [PATCH 12/38] Set benchmark on network investment example --- examples/Network Investment/Network Investment.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/Network Investment/Network Investment.ipynb b/examples/Network Investment/Network Investment.ipynb index 051ddd3..eb5d28f 100644 --- a/examples/Network Investment/Network Investment.ipynb +++ b/examples/Network Investment/Network Investment.ipynb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f9d1d8b275076f5b532ee4a67b59734ed19805e90dbc1b95d265e10867bcdb99 -size 8672681 +oid sha256:7e0a30c9ec682550bdd187a0d0b9db1b8c7152ae5e6a7545037a8ebeed8d48b5 +size 8681359 From 01cd73d12b68f33ee788030638eb13d448a018fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Wed, 9 Sep 2020 12:31:41 +0200 Subject: [PATCH 13/38] add domain package in solver for output.py input.py and numeric.py --- hadar/analyzer/result.py | 2 +- hadar/optimizer/lp/optimizer.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/hadar/analyzer/result.py b/hadar/analyzer/result.py index c689437..d9a392b 100644 --- a/hadar/analyzer/result.py +++ b/hadar/analyzer/result.py @@ -421,7 +421,7 @@ def _pivot(indexes, df: pd.DataFrame) -> pd.DataFrame: """ names = [i.column for i in indexes] mask = reduce(lambda a, b: a & b, (i.filter(df) for i in indexes)) - pt = pd.pivot_table(data=df[mask], index=names, aggfunc=lambda x: x.iloc[0]) + pt = pd.pivot_table(data=df[mask], index=names) return ResultAnalyzer._remove_useless_index_level(df=pt, indexes=indexes) diff --git a/hadar/optimizer/lp/optimizer.py b/hadar/optimizer/lp/optimizer.py index 2747d24..2a09d7d 100644 --- a/hadar/optimizer/lp/optimizer.py +++ b/hadar/optimizer/lp/optimizer.py @@ -4,7 +4,7 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - +import cProfile import logging import multiprocessing import pickle @@ -343,6 +343,17 @@ def _solve_batch(params) -> bytes: return pickle.dumps((variables, problem_build - start, problem_solved - start)) +def _wrap_profiler(param): + """ + Wrapper to start cprofile on _solve_batch. + DON'T USE IN PRODUCTION. + To use it, in solve_lp >> ... pool.map(_wrap_profiler, ...) + :param param: + :return: + """ + return cProfile.runctx('_solve_batch(param)', globals(), locals(), 'prof%d.prof' % param[1]) + + def solve_lp(study: Study, out_mapper=None) -> Result: """ Solve adequacy flow problem with a linear optimizer. From 73d5aef792175183833c5ff2e4e543214c05ca2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Wed, 9 Sep 2020 18:18:46 +0200 Subject: [PATCH 14/38] change pickle serialization to msgpack --- hadar/optimizer/domain/input.py | 4 +- hadar/optimizer/lp/domain.py | 137 +++++++++++++++------------ hadar/optimizer/lp/mapper.py | 16 ++-- hadar/optimizer/lp/optimizer.py | 8 +- hadar/optimizer/utils.py | 12 ++- requirements.txt | 3 +- tests/optimizer/lp/test_mapper.py | 24 ++--- tests/optimizer/lp/test_optimizer.py | 17 ++-- 8 files changed, 121 insertions(+), 100 deletions(-) diff --git a/hadar/optimizer/domain/input.py b/hadar/optimizer/domain/input.py index 9513d4a..7e3cb09 100644 --- a/hadar/optimizer/domain/input.py +++ b/hadar/optimizer/domain/input.py @@ -155,7 +155,7 @@ def to_json(self) -> dict: # Therefore when serialized we join these two strings with '::' to create on string as key # Ex: ('elec', 'a') --> 'elec::a' dict['src_ratios'] = {'::'.join(k): v.to_json() for k, v in self.src_ratios.items()} - return {k: JSON._convert(v) for k, v in dict.items()} + return {k: JSON.convert(v) for k, v in dict.items()} @staticmethod def from_json(dict: dict, factory=None): @@ -234,7 +234,7 @@ def __init__(self, horizon: int, nb_scn: int = 1, version: str = None): def to_json(self): # remove factory from serialization - return {k: JSON._convert(v) for k, v in self.__dict__.items() if k not in ['factory']} + return {k: JSON.convert(v) for k, v in self.__dict__.items() if k not in ['factory']} @staticmethod diff --git a/hadar/optimizer/lp/domain.py b/hadar/optimizer/lp/domain.py index aebe4f0..5e695d8 100644 --- a/hadar/optimizer/lp/domain.py +++ b/hadar/optimizer/lp/domain.py @@ -4,28 +4,45 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. +import numpy as np + +from abc import ABC, abstractmethod from typing import List, Union, Dict, Tuple from ortools.linear_solver.pywraplp import Variable -from hadar.optimizer.domain.input import Study -from hadar.optimizer.utils import DTO - +from hadar import Study +from hadar.optimizer.utils import JSON + + +class JSONLP(JSON, ABC): + def to_json(self): + def copy(v): + if isinstance(v, Variable): + return v.solution_value() + elif isinstance(v, dict): + # Json can't serialize tuple key, therefore join items with :: + return {'::'.join(k) if isinstance(k, tuple) else k: copy(v) for k, v in v.items()} + elif isinstance(v, np.int64): + return int(v) + elif isinstance(v, np.float64): + return float(v) + else: + return v + return {k: copy(v) for k, v in self.__dict__.items()} -class SerializableVariable(DTO): - def __init__(self, var: Variable): - self.val = var.solution_value() - - def solution_value(self): - return self.val + @staticmethod + @abstractmethod + def from_json(dict, factory=None): + pass -class LPConsumption(DTO): +class LPConsumption(JSONLP): """ Consumption element for linear programming. """ - def __init__(self, quantity: int, variable: Union[Variable, SerializableVariable], cost: float = 0, name: str = ''): + def __init__(self, quantity: int, variable: Union[Variable, float], cost: float = 0, name: str = ''): """ Instance consumption. @@ -39,20 +56,17 @@ def __init__(self, quantity: int, variable: Union[Variable, SerializableVariable self.name = name self.variable = variable - def __reduce__(self): - """ - Help pickle to serialize object, specially variable object - :return: (constructor, values...) - """ - return self.__class__, (self.quantity, SerializableVariable(self.variable), self.cost, self.name) + @staticmethod + def from_json(dict, factory=None): + return LPConsumption(**dict) -class LPProduction(DTO): +class LPProduction(JSONLP): """ Production element for linear programming. """ - def __init__(self, quantity: int, variable: Union[Variable, SerializableVariable], cost: float = 0, name: str = 'in'): + def __init__(self, quantity: int, variable: Union[Variable, float], cost: float = 0, name: str = 'in'): """ Instance production. @@ -66,21 +80,18 @@ def __init__(self, quantity: int, variable: Union[Variable, SerializableVariable self.variable = variable self.quantity = quantity - def __reduce__(self): - """ - Help pickle to serialize object, specially variable object - :return: (constructor, values...) - """ - return self.__class__, (self.quantity, SerializableVariable(self.variable), self.cost, self.name) + @staticmethod + def from_json(dict, factory=None): + return LPProduction(**dict) -class LPStorage(DTO): +class LPStorage(JSONLP): """ Storage element """ - def __init__(self, name, capacity: int, var_capacity: Union[Variable, SerializableVariable], - flow_in: float, var_flow_in: Union[Variable, SerializableVariable], - flow_out: float, var_flow_out: Union[Variable, SerializableVariable], + def __init__(self, name, capacity: int, var_capacity: Union[Variable, float], + flow_in: float, var_flow_in: Union[Variable, float], + flow_out: float, var_flow_out: Union[Variable, float], cost: float = 0, init_capacity: int = 0, eff: float = .99): """ Create storage. @@ -106,22 +117,16 @@ def __init__(self, name, capacity: int, var_capacity: Union[Variable, Serializab self.init_capacity = init_capacity self.eff = eff - def __reduce__(self): - """ - Help pickle to serialize object, specially variable object - :return: (constructor, values...) - """ - return self.__class__, (self.name, self.capacity, SerializableVariable(self.var_capacity), - self.flow_in, SerializableVariable(self.var_flow_in), - self.flow_out, SerializableVariable(self.var_flow_out), - self.cost, self.init_capacity, self.eff) + @staticmethod + def from_json(dict, factory=None): + return LPStorage(**dict) -class LPLink(DTO): +class LPLink(JSONLP): """ Link element for linear programming """ - def __init__(self, src: str, dest: str, quantity: int, variable: Union[Variable, SerializableVariable], cost: float = 0): + def __init__(self, src: str, dest: str, quantity: int, variable: Union[Variable, float], cost: float = 0): """ Instance Link. @@ -137,22 +142,19 @@ def __init__(self, src: str, dest: str, quantity: int, variable: Union[Variable, self.variable = variable self.cost = cost - def __reduce__(self): - """ - Help pickle to serialize object, specially variable object - :return: (constructor, values...) - """ - return self.__class__, (self.src, self.dest, self.quantity, SerializableVariable(self.variable), self.cost) + @staticmethod + def from_json(dict, factory=None): + return LPLink(**dict) -class LPConverter(DTO): +class LPConverter(JSONLP): """ Converter element for linear programming """ def __init__(self, name: str, src_ratios: Dict[Tuple[str, str], float], - var_flow_src: Dict[Tuple[str, str], Union[Variable, SerializableVariable]], + var_flow_src: Dict[Tuple[str, str], Union[Variable, float]], dest_network: str, dest_node: str, - var_flow_dest: Union[Variable, SerializableVariable], + var_flow_dest: Union[Variable, float], cost: float, max: float,): """ Create converter. @@ -176,16 +178,15 @@ def __init__(self, name: str, src_ratios: Dict[Tuple[str, str], float], self.cost = cost self.max = max - def __reduce__(self): - """ - Help pickle to serialize object, specially variable object - :return: (constructor, values...) - """ - return self.__class__, (self.name, self.src_ratios, {src: SerializableVariable(var) for src, var in self.var_flow_src.items()}, - self.dest_network, self.dest_node, SerializableVariable(self.var_flow_dest), self.cost, self.max) + @staticmethod + def from_json(dict, factory=None): + # Json can't serialize tuple as key. tuple is concatained before serialized, we need to extract it now + dict['src_ratios'] = {tuple(k.split('::')): v for k, v in dict['src_ratios'].items()} + dict['var_flow_src'] = {tuple(k.split('::')): v for k, v in dict['var_flow_src'].items()} + return LPConverter(**dict) -class LPNode(DTO): +class LPNode(JSON): """ Node element for linear programming """ @@ -203,8 +204,16 @@ def __init__(self, consumptions: List[LPConsumption], productions: List[LPProduc self.storages = storages self.links = links + @staticmethod + def from_json(dict, factory=None): + dict['consumptions'] = [LPConsumption.from_json(v) for v in dict['consumptions']] + dict['productions'] = [LPProduction.from_json(v) for v in dict['productions']] + dict['storages'] = [LPStorage.from_json(v) for v in dict['storages']] + dict['links'] = [LPLink.from_json(v) for v in dict['links']] + return LPNode(**dict) + -class LPNetwork(DTO): +class LPNetwork(JSON): """ Network element for linear programming """ @@ -217,8 +226,13 @@ def __init__(self, nodes: Dict[str, LPNode] = None): """ self.nodes = nodes if nodes else dict() + @staticmethod + def from_json(dict, factory=None): + dict['nodes'] = {k: LPNode.from_json(v) for k, v in dict['nodes'].items()} + return LPNetwork(**dict) + -class LPTimeStep(DTO): +class LPTimeStep(JSON): def __init__(self, networks: Dict[str, LPNetwork], converters: Dict[str, LPConverter]): self.networks = networks self.converters = converters @@ -228,3 +242,8 @@ def create_like_study(study: Study): networks = {name: LPNetwork() for name in study.networks} converters = dict() return LPTimeStep(networks=networks, converters=converters) + + @staticmethod + def from_json(dict, factory=None): + return LPTimeStep(networks={k: LPNetwork.from_json(v) for k, v in dict['networks'].items()}, + converters={k: LPConverter.from_json(v) for k, v in dict['converters'].items()}) \ No newline at end of file diff --git a/hadar/optimizer/lp/mapper.py b/hadar/optimizer/lp/mapper.py index 37a42dc..8c3b242 100644 --- a/hadar/optimizer/lp/mapper.py +++ b/hadar/optimizer/lp/mapper.py @@ -113,23 +113,23 @@ def set_node_var(self, network: str, node: str, t: int, scn: int, vars: LPNode): """ out_node = self.networks[network].nodes[node] for i in range(len(vars.consumptions)): - out_node.consumptions[i].quantity[scn, t] = vars.consumptions[i].quantity - vars.consumptions[i].variable.solution_value() + out_node.consumptions[i].quantity[scn, t] = vars.consumptions[i].quantity - vars.consumptions[i].variable for i in range(len(vars.productions)): - out_node.productions[i].quantity[scn, t] = vars.productions[i].variable.solution_value() + out_node.productions[i].quantity[scn, t] = vars.productions[i].variable for i in range(len(vars.storages)): - out_node.storages[i].capacity[scn, t] = vars.storages[i].var_capacity.solution_value() - out_node.storages[i].flow_in[scn, t] = vars.storages[i].var_flow_in.solution_value() - out_node.storages[i].flow_out[scn, t] = vars.storages[i].var_flow_out.solution_value() + out_node.storages[i].capacity[scn, t] = vars.storages[i].var_capacity + out_node.storages[i].flow_in[scn, t] = vars.storages[i].var_flow_in + out_node.storages[i].flow_out[scn, t] = vars.storages[i].var_flow_out for i in range(len(vars.links)): - self.networks[network].nodes[node].links[i].quantity[scn, t] = vars.links[i].variable.solution_value() + self.networks[network].nodes[node].links[i].quantity[scn, t] = vars.links[i].variable def set_converter_var(self, name: str, t: int, scn: int, vars: LPConverter): for src, var in vars.var_flow_src.items(): - self.converters[name].flow_src[src][scn, t] = var.solution_value() - self.converters[name].flow_dest[scn, t] = vars.var_flow_dest.solution_value() + self.converters[name].flow_src[src][scn, t] = var + self.converters[name].flow_dest[scn, t] = vars.var_flow_dest def get_result(self) -> Result: """ diff --git a/hadar/optimizer/lp/optimizer.py b/hadar/optimizer/lp/optimizer.py index 2a09d7d..2570201 100644 --- a/hadar/optimizer/lp/optimizer.py +++ b/hadar/optimizer/lp/optimizer.py @@ -7,16 +7,17 @@ import cProfile import logging import multiprocessing -import pickle import time from typing import List +import msgpack from ortools.linear_solver.pywraplp import Solver, Constraint from hadar.optimizer.domain.input import Study from hadar.optimizer.lp.domain import LPNode, LPProduction, LPConsumption, LPLink, LPStorage, LPTimeStep, LPConverter from hadar.optimizer.lp.mapper import InputMapper, OutputMapper from hadar.optimizer.domain.output import Result, Benchmark +from hadar.optimizer.utils import JSON logger = logging.getLogger(__name__) @@ -340,7 +341,7 @@ def _solve_batch(params) -> bytes: # When multiprocessing handle response and serialize it with pickle, # it's occur that ortools variables seem already erased. # To fix this situation, serialization is handle inside 'job scope' - return pickle.dumps((variables, problem_build - start, problem_solved - start)) + return msgpack.packb((JSON.convert(variables), problem_build - start, problem_solved - start), use_bin_type=True) def _wrap_profiler(param): @@ -372,10 +373,11 @@ def solve_lp(study: Study, out_mapper=None) -> Result: compute_finished = time.time() for scn in range(0, study.nb_scn): - variables, modeler, solver = pickle.loads(serialized_out[scn]) + variables, modeler, solver = msgpack.unpackb(serialized_out[scn], use_list=False, raw=False) benchmark.modeler.append(modeler) benchmark.solver.append(solver) + variables = [LPTimeStep.from_json(v) for v in variables] for t in range(0, study.horizon): # Set node elements for name_network, network in study.networks.items(): diff --git a/hadar/optimizer/utils.py b/hadar/optimizer/utils.py index 8dcf724..83bc122 100644 --- a/hadar/optimizer/utils.py +++ b/hadar/optimizer/utils.py @@ -31,19 +31,23 @@ class JSON(DTO, ABC): """ @staticmethod - def _convert(value): + def convert(value): if isinstance(value, JSON): return value.to_json() elif isinstance(value, dict): - return {k: JSON._convert(v) for k, v in value.items()} + return {k: JSON.convert(v) for k, v in value.items()} elif isinstance(value, list) or isinstance(value, tuple): - return [JSON._convert(v) for v in value] + return [JSON.convert(v) for v in value] + elif isinstance(value, np.int64): + return int(value) + elif isinstance(value, np.float64): + return float(value) elif isinstance(value, np.ndarray): return value.tolist() return value def to_json(self): - return {k: JSON._convert(v) for k, v in self.__dict__.items()} + return {k: JSON.convert(v) for k, v in self.__dict__.items()} @staticmethod @abstractmethod diff --git a/requirements.txt b/requirements.txt index c433e70..1851a70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ ortools plotly matplotlib requests -progress \ No newline at end of file +progress +msgpack \ No newline at end of file diff --git a/tests/optimizer/lp/test_mapper.py b/tests/optimizer/lp/test_mapper.py index e9394b7..3427a25 100644 --- a/tests/optimizer/lp/test_mapper.py +++ b/tests/optimizer/lp/test_mapper.py @@ -162,11 +162,11 @@ def test_map_consumption(self): mapper = OutputMapper(study=study) - out_cons_0 = [LPConsumption(name='load', cost=.01, quantity=10, variable=MockNumVar(0, 5, ''))] + out_cons_0 = [LPConsumption(name='load', cost=.01, quantity=10, variable=5)] mapper.set_node_var(network='default', node='a', t=0, scn=0, vars=LPNode(consumptions=out_cons_0, productions=[], storages=[], links=[])) - out_cons_1 = [LPConsumption(name='load', cost=.2, quantity=20, variable=MockNumVar(0, 5, ''))] + out_cons_1 = [LPConsumption(name='load', cost=.2, quantity=20, variable=5)] mapper.set_node_var(network='default', node='a', t=1, scn=1, vars=LPNode(consumptions=out_cons_1, productions=[], storages=[], links=[])) @@ -187,11 +187,11 @@ def test_map_production(self): mapper = OutputMapper(study=study) - out_prod_0 = [LPProduction(name='nuclear', cost=.12, quantity=12, variable=MockNumVar(0, 12, ''))] + out_prod_0 = [LPProduction(name='nuclear', cost=.12, quantity=12, variable=12)] mapper.set_node_var(network='default', node='a', t=0, scn=0, vars=LPNode(consumptions=[], productions=out_prod_0, storages=[], links=[])) - out_prod_1 = [LPProduction(name='nuclear', cost=.21, quantity=2, variable=MockNumVar(0, 112, ''))] + out_prod_1 = [LPProduction(name='nuclear', cost=.21, quantity=2, variable=112)] mapper.set_node_var(network='default', node='a', t=1, scn=1, vars=LPNode(consumptions=[], productions=out_prod_1, storages=[], links=[])) @@ -213,16 +213,12 @@ def test_map_storage(self): mapper = OutputMapper(study=study) out_stor_0 = [LPStorage(name='cell', capacity=10, flow_in=1, flow_out=1, init_capacity=2, eff=.9, cost=1, - var_capacity=MockNumVar(0, 5, ''), - var_flow_in=MockNumVar(0, 2, ''), - var_flow_out=MockNumVar(0, 4, ''))] + var_capacity=5, var_flow_in=2, var_flow_out=4)] mapper.set_node_var(network='default', node='a', t=0, scn=0, vars=LPNode(consumptions=[], productions=[], storages=out_stor_0, links=[])) out_stor_1 = [LPStorage(name='cell', capacity=10, flow_in=1, flow_out=1, init_capacity=2, eff=.9, cost=1, - var_capacity=MockNumVar(0, 55, ''), - var_flow_in=MockNumVar(0, 22, ''), - var_flow_out=MockNumVar(0, 44, ''))] + var_capacity=55, var_flow_in=22, var_flow_out=44)] mapper.set_node_var(network='default', node='a', t=1, scn=1, vars=LPNode(consumptions=[], productions=[], storages=out_stor_1, links=[])) @@ -244,11 +240,11 @@ def test_map_link(self): mapper = OutputMapper(study=study) - out_link_0 = [LPLink(src='a', dest='be', cost=.01, quantity=10, variable=MockNumVar(0, 8, ''))] + out_link_0 = [LPLink(src='a', dest='be', cost=.01, quantity=10, variable=8)] mapper.set_node_var(network='default', node='a', t=0, scn=0, vars=LPNode(consumptions=[], productions=[], storages=[], links=out_link_0)) - out_link_1 = [LPLink(src='a', dest='be', cost=.02, quantity=10, variable=MockNumVar(0, 18, ''))] + out_link_1 = [LPLink(src='a', dest='be', cost=.02, quantity=10, variable=18)] mapper.set_node_var(network='default', node='a', t=1, scn=1, vars=LPNode(consumptions=[], productions=[], storages=[], links=out_link_1)) @@ -276,9 +272,7 @@ def test_map_converter(self): blank_node = OutputNode(consumptions=[], productions=[], storages=[], links=[]) mapper = OutputMapper(study=study) vars = LPConverter(name='conv', src_ratios={('gas', 'a'): 0.5}, dest_network='default', dest_node='b', - cost=0, max=100, - var_flow_dest=MockNumVar(0, 100, 'flow_dest conv %s'), - var_flow_src={('gas', 'a'): MockNumVar(0, 200, 'flow_src conv gas:a %s')}) + cost=0, max=100, var_flow_dest=100, var_flow_src={('gas', 'a'): 200}) mapper.set_converter_var(name='conv', t=0, scn=0, vars=vars) res = mapper.get_result() diff --git a/tests/optimizer/lp/test_optimizer.py b/tests/optimizer/lp/test_optimizer.py index 0109aa4..e39d3ae 100644 --- a/tests/optimizer/lp/test_optimizer.py +++ b/tests/optimizer/lp/test_optimizer.py @@ -4,12 +4,13 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. -import pickle import unittest from unittest.mock import MagicMock, call, ANY, Mock +import msgpack + from hadar.optimizer.domain.input import Study -from hadar.optimizer.lp.domain import LPConsumption, LPProduction, LPLink, LPNode, SerializableVariable, LPStorage, \ +from hadar.optimizer.lp.domain import LPConsumption, LPProduction, LPLink, LPNode, LPStorage, \ LPConverter, LPTimeStep, LPNetwork from hadar.optimizer.lp.mapper import InputMapper, OutputMapper from hadar.optimizer.lp.optimizer import ObjectiveBuilder, AdequacyBuilder, _solve_batch, StorageBuilder, \ @@ -251,12 +252,12 @@ def side_effect(network, node, t, scn): in_mapper.get_conv_var = Mock(return_value=exp_var_conv) # Expected - in_cons = LPConsumption(name='load', quantity=10, cost=10, variable=SerializableVariable(MockNumVar(0, 10, 'load'))) + in_cons = LPConsumption(name='load', quantity=10, cost=10, variable=10) exp_var_node = LPNode(consumptions=[in_cons], productions=[], storages=[], links=[]) exp_var_conv = LPConverter(name='conv', src_ratios={('default', 'a'): .5}, - var_flow_src={('default', 'a'): SerializableVariable(MockNumVar(0, 10, 'conv src'))}, - dest_network='gas', dest_node='b', max=10, cost=1, - var_flow_dest=SerializableVariable(MockNumVar(0, 10, 'conv dest'))) + var_flow_src={('default', 'a'): 10}, + dest_network='gas', dest_node='b', max=10, cost=1, + var_flow_dest=10) expected = LPTimeStep(networks={'default': LPNetwork(nodes={'a': exp_var_node}), 'gas': LPNetwork(nodes={'b': empty_node})}, @@ -264,8 +265,8 @@ def side_effect(network, node, t, scn): # Test res = _solve_batch((study, 0, solver, objective, adequacy, storage, mix, in_mapper)) - res, t_mod, t_sol = pickle.loads(res) - self.assertEqual([expected], res) + res, t_mod, t_sol = msgpack.unpackb(res, use_list=False, raw=False) + self.assertEqual([expected], [LPTimeStep.from_json(r) for r in res]) self.assertTrue(t_mod > 0) self.assertTrue(t_sol > 0) From 1c96622c26d3c6886863fa67e9f1f25b5efec416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Thu, 10 Sep 2020 14:40:04 +0200 Subject: [PATCH 15/38] Save profiling for 12 scenarios case. update notebook --- examples/Network Investment/12scn.prof | Bin 0 -> 696922 bytes .../Network Investment.ipynb | 4 ++-- 2 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 examples/Network Investment/12scn.prof diff --git a/examples/Network Investment/12scn.prof b/examples/Network Investment/12scn.prof new file mode 100644 index 0000000000000000000000000000000000000000..479e388d4a388326ce6584ee65b69fd1c2051c1f GIT binary patch literal 696922 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∈GL_Aw+l?3( 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�{8lWgA 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

ewESz(hxHi?+cB)WJR;1^@qY${vH!mJ zVu<}r2IA&?&k5blw9BiG8Udn`|3P*XFp!@|fMZjuUvMpL+xR;6zC&?=13 zT4PduE7xVPX2bH^M!e;{ z8CW8TKF+{l@>UYPh!c~uI}Z=qu@KNR!pqRS!z6|UeL6lChWiO?Yyk`eitAE#oON*Jgn2J^*)s-3<`X5 zkH}l;+!oXiKzPmhxIAiB800(})r_kma3u;D_l$IH2szHZp>+#dCbEasScyHyq;u(! z#-y8C;}d7$Cwh&|HqHa*0*CVa-;mCdUp$t=Kgk3dD=9n@mDGI{WX#T_v;Gf|nYEzp zTRk=52XIqCYmi@v4F5em-xEMdstulv6S6YLAEQA4OZZf8o ziIBC2E_>&T5r;!tRk63ih{;@qhiQ6_uB)vxwP8c9l-Mn8T+iVrdf87jRKqUQ*7(L; zXvO)iBk|rzKYfe6q_ul{`Bl%###!|ce}qNa?hk8P|Hcx){6RYSwt8r3B%k?+JJI*0q`ky$m-Ubw3>U{ibXEwfw`X0F!{huBLRuoYr9P1l_oqpUmka zf%K4L|8nSv1^fG2ufj;{9b+e*Mq~Da#%vL@RNaNgKPZfR;d1;!*J)v{Si|~ZD!&!! z`)~oi)pS~Fo$X+81@23R=mGoq)~MYR@C&_d@STB^_t3+)!dHp}uv7&w8Oj;MJ&~2d zU=7~}F$txJtV{MPgkLiv;FF^st5F!?oPxyB{vfxiBm+@Sj@4|c!0W+6S_zDB?hP(H wUp9UE_%Q|HFEtL>AjB^oLHQ@a2 Date: Tue, 15 Sep 2020 11:52:34 +0200 Subject: [PATCH 16/38] update documentation. Add mock imports --- docs/requirements.txt | 8 ---- docs/source/conf.py | 11 +++++- docs/source/reference/hadar.analyzer.rst | 1 - .../reference/hadar.optimizer.domain.rst | 37 +++++++++++++++++++ docs/source/reference/hadar.optimizer.lp.rst | 1 - .../reference/hadar.optimizer.remote.rst | 1 - docs/source/reference/hadar.optimizer.rst | 16 ++------ docs/source/reference/hadar.viewer.rst | 1 - docs/source/reference/hadar.workflow.rst | 1 - hadar/workflow/shuffler.py | 2 +- 10 files changed, 51 insertions(+), 28 deletions(-) create mode 100644 docs/source/reference/hadar.optimizer.domain.rst diff --git a/docs/requirements.txt b/docs/requirements.txt index 6603dac..71f543e 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,11 +1,3 @@ -pandas -numpy -ortools -plotly -jupyter -matplotlib -requests -progress sphinx sphinx-rtd-theme sphinx-autobuild \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 275a0a1..946fc80 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,8 +12,17 @@ # import os import sys +from unittest.mock import Mock + sys.path.insert(0, os.path.abspath('../..')) +with open('../../requirements.txt') as f: + imports = f.read().split('\n') + ['numpy.random', 'ortools.linear_solver.pywraplp', 'progress.bar', + 'progress.spinner', 'plotly.graph_objects', 'matplotlib.cm', 'requests.exceptions'] + for i in imports: + sys.modules[i] = Mock() +print(imports) + import hadar # -- Project information ----------------------------------------------------- @@ -58,5 +67,3 @@ html_static_path = ['_static'] nbsphinx_execute = 'never' - -autodoc_mock_imports = ['pandas', 'numpy', 'ortools', 'plotly', 'jupyter', 'matplotlib', 'requests', 'progress'] \ No newline at end of file diff --git a/docs/source/reference/hadar.analyzer.rst b/docs/source/reference/hadar.analyzer.rst index b91f9fd..6636e3a 100644 --- a/docs/source/reference/hadar.analyzer.rst +++ b/docs/source/reference/hadar.analyzer.rst @@ -12,7 +12,6 @@ hadar.analyzer.result module :undoc-members: :show-inheritance: - Module contents --------------- diff --git a/docs/source/reference/hadar.optimizer.domain.rst b/docs/source/reference/hadar.optimizer.domain.rst new file mode 100644 index 0000000..8df85f5 --- /dev/null +++ b/docs/source/reference/hadar.optimizer.domain.rst @@ -0,0 +1,37 @@ +hadar.optimizer.domain package +============================== + +Submodules +---------- + +hadar.optimizer.domain.input module +----------------------------------- + +.. automodule:: hadar.optimizer.domain.input + :members: + :undoc-members: + :show-inheritance: + +hadar.optimizer.domain.numeric module +------------------------------------- + +.. automodule:: hadar.optimizer.domain.numeric + :members: + :undoc-members: + :show-inheritance: + +hadar.optimizer.domain.output module +------------------------------------ + +.. automodule:: hadar.optimizer.domain.output + :members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: hadar.optimizer.domain + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/reference/hadar.optimizer.lp.rst b/docs/source/reference/hadar.optimizer.lp.rst index 88b77b3..6f134a7 100644 --- a/docs/source/reference/hadar.optimizer.lp.rst +++ b/docs/source/reference/hadar.optimizer.lp.rst @@ -28,7 +28,6 @@ hadar.optimizer.lp.optimizer module :undoc-members: :show-inheritance: - Module contents --------------- diff --git a/docs/source/reference/hadar.optimizer.remote.rst b/docs/source/reference/hadar.optimizer.remote.rst index a8a4dc9..4202b13 100644 --- a/docs/source/reference/hadar.optimizer.remote.rst +++ b/docs/source/reference/hadar.optimizer.remote.rst @@ -12,7 +12,6 @@ hadar.optimizer.remote.optimizer module :undoc-members: :show-inheritance: - Module contents --------------- diff --git a/docs/source/reference/hadar.optimizer.rst b/docs/source/reference/hadar.optimizer.rst index 60bf598..410ade5 100644 --- a/docs/source/reference/hadar.optimizer.rst +++ b/docs/source/reference/hadar.optimizer.rst @@ -7,20 +7,13 @@ Subpackages .. toctree:: :maxdepth: 4 + hadar.optimizer.domain hadar.optimizer.lp hadar.optimizer.remote Submodules ---------- -hadar.optimizer.input module ----------------------------- - -.. automodule:: hadar.optimizer.input - :members: - :undoc-members: - :show-inheritance: - hadar.optimizer.optimizer module -------------------------------- @@ -29,15 +22,14 @@ hadar.optimizer.optimizer module :undoc-members: :show-inheritance: -hadar.optimizer.output module ------------------------------ +hadar.optimizer.utils module +---------------------------- -.. automodule:: hadar.optimizer.output +.. automodule:: hadar.optimizer.utils :members: :undoc-members: :show-inheritance: - Module contents --------------- diff --git a/docs/source/reference/hadar.viewer.rst b/docs/source/reference/hadar.viewer.rst index 8abde1f..dad955c 100644 --- a/docs/source/reference/hadar.viewer.rst +++ b/docs/source/reference/hadar.viewer.rst @@ -20,7 +20,6 @@ hadar.viewer.html module :undoc-members: :show-inheritance: - Module contents --------------- diff --git a/docs/source/reference/hadar.workflow.rst b/docs/source/reference/hadar.workflow.rst index e625506..318a574 100644 --- a/docs/source/reference/hadar.workflow.rst +++ b/docs/source/reference/hadar.workflow.rst @@ -20,7 +20,6 @@ hadar.workflow.shuffler module :undoc-members: :show-inheritance: - Module contents --------------- diff --git a/hadar/workflow/shuffler.py b/hadar/workflow/shuffler.py index 322034f..ebf555c 100644 --- a/hadar/workflow/shuffler.py +++ b/hadar/workflow/shuffler.py @@ -8,7 +8,7 @@ import numpy as np import pandas as pd -from numpy.random.mtrand import randint +from numpy.random import randint from hadar.workflow.pipeline import Pipeline, TO_SHUFFLER, Stage From 9997d6f77cebee9d1309331519eae39e66fe7eb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Tue, 15 Sep 2020 13:39:29 +0200 Subject: [PATCH 17/38] Disable viewer test for other linux and windows --- tests/viewer/test_html.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/viewer/test_html.py b/tests/viewer/test_html.py index 6385a66..a19182a 100644 --- a/tests/viewer/test_html.py +++ b/tests/viewer/test_html.py @@ -5,6 +5,7 @@ # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. import hashlib +import sys import unittest import plotly.graph_objects as go @@ -146,6 +147,9 @@ def test_converter(self): self.assert_fig_hash('32a6e175600822c833a9b7f3008aa35230b0b646', fig) def assert_fig_hash(self, expected: str, fig: go.Figure): + if sys.platform != 'darwin': # We only test graphics for MacOS, there are little change with other distrib + return self.assertTrue(True) + actual = hashlib.sha1(TestHTMLPlotting.get_html(fig)).hexdigest() if expected != actual: fig.show() From 6be1288e9d10e66d1675090556cf0cca74704bd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Tue, 15 Sep 2020 13:53:00 +0200 Subject: [PATCH 18/38] Disable viewer test for other than macOS and python3.7 --- tests/viewer/test_html.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/viewer/test_html.py b/tests/viewer/test_html.py index a19182a..1a06ac8 100644 --- a/tests/viewer/test_html.py +++ b/tests/viewer/test_html.py @@ -16,6 +16,8 @@ from hadar.optimizer.optimizer import LPOptimizer from hadar.viewer.html import HTMLPlotting +ma, mi, _, _, _ = sys.version_info + class TestHTMLPlotting(unittest.TestCase): def setUp(self) -> None: @@ -147,7 +149,7 @@ def test_converter(self): self.assert_fig_hash('32a6e175600822c833a9b7f3008aa35230b0b646', fig) def assert_fig_hash(self, expected: str, fig: go.Figure): - if sys.platform != 'darwin': # We only test graphics for MacOS, there are little change with other distrib + if sys.platform != 'darwin' and (ma, mi) == (3, 7): # We only test graphics for MacOS, there are little change with other distrib return self.assertTrue(True) actual = hashlib.sha1(TestHTMLPlotting.get_html(fig)).hexdigest() From 75bf695a7f711ecbe1cf6340c894d1d7b2fd44fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Tue, 15 Sep 2020 15:02:46 +0200 Subject: [PATCH 19/38] Disable viewer test for other than macOS and python3.7 --- tests/viewer/test_html.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/viewer/test_html.py b/tests/viewer/test_html.py index 1a06ac8..c7de790 100644 --- a/tests/viewer/test_html.py +++ b/tests/viewer/test_html.py @@ -149,7 +149,7 @@ def test_converter(self): self.assert_fig_hash('32a6e175600822c833a9b7f3008aa35230b0b646', fig) def assert_fig_hash(self, expected: str, fig: go.Figure): - if sys.platform != 'darwin' and (ma, mi) == (3, 7): # We only test graphics for MacOS, there are little change with other distrib + if sys.platform != 'darwin' or (ma, mi) != (3, 7): # We only test graphics for MacOS, there are little change with other distrib return self.assertTrue(True) actual = hashlib.sha1(TestHTMLPlotting.get_html(fig)).hexdigest() From a38c0ddb7d2ee9d3b9dab6f6a077ce7a4a0227f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Tue, 15 Sep 2020 15:19:38 +0200 Subject: [PATCH 20/38] restrict dependencies version --- requirements.txt | 16 ++++++++-------- tests/viewer/test_html.py | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/requirements.txt b/requirements.txt index 1851a70..abf6b1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -pandas -numpy -ortools -plotly -matplotlib -requests -progress -msgpack \ No newline at end of file +pandas>=1.0.3,<2.0.0 +numpy>=1.18.2,<2.0.0 +ortools>=7.5.7466,<8.0.0 +plotly==4.8.1 +matplotlib>=3.2.1,<4.0.0 +requests>=2.23.0,<3.0.0 +progress>=1.5,<2 +msgpack>=1.0.0,<2.0.0 \ No newline at end of file diff --git a/tests/viewer/test_html.py b/tests/viewer/test_html.py index c7de790..be6637d 100644 --- a/tests/viewer/test_html.py +++ b/tests/viewer/test_html.py @@ -149,8 +149,8 @@ def test_converter(self): self.assert_fig_hash('32a6e175600822c833a9b7f3008aa35230b0b646', fig) def assert_fig_hash(self, expected: str, fig: go.Figure): - if sys.platform != 'darwin' or (ma, mi) != (3, 7): # We only test graphics for MacOS, there are little change with other distrib - return self.assertTrue(True) + # if sys.platform != 'darwin' or (ma, mi) != (3, 7): # We only test graphics for MacOS, there are little change with other distrib + # return self.assertTrue(True) actual = hashlib.sha1(TestHTMLPlotting.get_html(fig)).hexdigest() if expected != actual: From 0280bcb7f52eedd7ed701d3001e1ec1d44219878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Tue, 15 Sep 2020 15:32:29 +0200 Subject: [PATCH 21/38] fix dependencies extration in sphinx --- docs/source/conf.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 946fc80..66184ec 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -16,9 +16,23 @@ sys.path.insert(0, os.path.abspath('../..')) + +def remove_version(req: str): + """ + Remove version in string like 'package==4.3.1' or 'package>=8.4.7' + :param req: + :return: + """ + for sep in ['>=', '==']: + if sep in req: + return req.split(sep)[0] + return req + + with open('../../requirements.txt') as f: - imports = f.read().split('\n') + ['numpy.random', 'ortools.linear_solver.pywraplp', 'progress.bar', - 'progress.spinner', 'plotly.graph_objects', 'matplotlib.cm', 'requests.exceptions'] + imports = [remove_version(r) for r in f.read().split('\n')] +\ + ['numpy.random', 'ortools.linear_solver.pywraplp', 'progress.bar', 'progress.spinner', + 'plotly.graph_objects', 'matplotlib.cm', 'requests.exceptions'] for i in imports: sys.modules[i] = Mock() print(imports) From 1dcbc00b1b778549ae0b2f1938ccd78b320fc002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Wed, 16 Sep 2020 11:52:13 +0200 Subject: [PATCH 22/38] Change sphinx theme from readthedoc to pydata --- docs/requirements.txt | 3 ++- docs/source/_static/logo.png | Bin 0 -> 69035 bytes docs/source/architecture/analyzer.rst | 2 ++ docs/source/architecture/index.rst | 12 +++++++++ docs/source/architecture/optimizer.rst | 2 ++ docs/source/architecture/overview.rst | 4 ++- docs/source/architecture/viewer.rst | 2 ++ docs/source/architecture/workflow.rst | 2 ++ docs/source/conf.py | 3 ++- docs/source/dev-guide/contributing.rst | 2 ++ docs/source/dev-guide/index.rst | 10 ++++++++ docs/source/dev-guide/repository.rst | 2 ++ docs/source/index.rst | 33 +++++++++---------------- docs/source/mathematics/index.rst | 8 ++++++ docs/source/reference/modules.rst | 4 +-- docs/source/terms/terms.rst | 2 ++ 16 files changed, 64 insertions(+), 27 deletions(-) create mode 100644 docs/source/_static/logo.png create mode 100644 docs/source/architecture/index.rst create mode 100644 docs/source/dev-guide/index.rst create mode 100644 docs/source/mathematics/index.rst diff --git a/docs/requirements.txt b/docs/requirements.txt index 71f543e..c61bab7 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ sphinx sphinx-rtd-theme -sphinx-autobuild \ No newline at end of file +sphinx-autobuild +pydata-sphinx-theme \ No newline at end of file diff --git a/docs/source/_static/logo.png b/docs/source/_static/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2c734e4e8e176f9b62addb918f650ab89df62486 GIT binary patch literal 69035 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 z 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 literal 0 HcmV?d00001 diff --git a/docs/source/architecture/analyzer.rst b/docs/source/architecture/analyzer.rst index b403698..445c9f4 100644 --- a/docs/source/architecture/analyzer.rst +++ b/docs/source/architecture/analyzer.rst @@ -1,3 +1,5 @@ +.. _analyzer: + Analyzer ======== diff --git a/docs/source/architecture/index.rst b/docs/source/architecture/index.rst new file mode 100644 index 0000000..8d4acb7 --- /dev/null +++ b/docs/source/architecture/index.rst @@ -0,0 +1,12 @@ +============ +Architecture +============ + +.. toctree:: + :maxdepth: 2 + + overview + workflow + optimizer + analyzer + viewer \ No newline at end of file diff --git a/docs/source/architecture/optimizer.rst b/docs/source/architecture/optimizer.rst index 1034b09..44d9a6b 100644 --- a/docs/source/architecture/optimizer.rst +++ b/docs/source/architecture/optimizer.rst @@ -1,3 +1,5 @@ +.. _optimizer: + Optimizer ========= diff --git a/docs/source/architecture/overview.rst b/docs/source/architecture/overview.rst index b96218d..9e6f1d1 100644 --- a/docs/source/architecture/overview.rst +++ b/docs/source/architecture/overview.rst @@ -1,5 +1,7 @@ +.. _overview: + Overview -======== +============ Welcome to the Hadar Architecture Documentation. diff --git a/docs/source/architecture/viewer.rst b/docs/source/architecture/viewer.rst index 3488be1..304b663 100644 --- a/docs/source/architecture/viewer.rst +++ b/docs/source/architecture/viewer.rst @@ -1,3 +1,5 @@ +.. _viewer: + Viewer ====== diff --git a/docs/source/architecture/workflow.rst b/docs/source/architecture/workflow.rst index 3d4dace..a63275b 100644 --- a/docs/source/architecture/workflow.rst +++ b/docs/source/architecture/workflow.rst @@ -1,3 +1,5 @@ +.. _workflow: + Workflow ======== diff --git a/docs/source/conf.py b/docs/source/conf.py index 66184ec..bf7dd3a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -73,7 +73,8 @@ def remove_version(req: str): # The theme to use for HTML and HTML Help pages. See the reference for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = 'pydata_sphinx_theme' +html_logo = "_static/logo.png" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/source/dev-guide/contributing.rst b/docs/source/dev-guide/contributing.rst index 2c2454d..4cdc0f5 100644 --- a/docs/source/dev-guide/contributing.rst +++ b/docs/source/dev-guide/contributing.rst @@ -1,3 +1,5 @@ +.. _contributing: + How to Contribute ================= diff --git a/docs/source/dev-guide/index.rst b/docs/source/dev-guide/index.rst new file mode 100644 index 0000000..6f277fb --- /dev/null +++ b/docs/source/dev-guide/index.rst @@ -0,0 +1,10 @@ +========= +Dev Guide +========= + + +.. toctree:: + :maxdepth: 2 + + contributing + repository \ No newline at end of file diff --git a/docs/source/dev-guide/repository.rst b/docs/source/dev-guide/repository.rst index 7577429..4297c88 100644 --- a/docs/source/dev-guide/repository.rst +++ b/docs/source/dev-guide/repository.rst @@ -1,3 +1,5 @@ +.. _repository: + Repository Organization ======================= diff --git a/docs/source/index.rst b/docs/source/index.rst index 35e61a8..02fba22 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -30,39 +30,28 @@ You are in the technical documentation. .. toctree:: - :maxdepth: 1 - :caption: Architecture: + :maxdepth: 2 + + architecture/index.rst - architecture/overview.rst - architecture/workflow.rst - architecture/optimizer.rst - architecture/analyzer.rst - architecture/viewer.rst .. toctree:: - :maxdepth: 1 - :caption: Mathematics: + :maxdepth: 2 - mathematics/linear-model.rst + mathematics/index.rst .. toctree:: - :maxdepth: 1 - :caption: Dev Guide: + :maxdepth: 2 - dev-guide/repository.rst - dev-guide/contributing.rst + dev-guide/index.rst .. toctree:: - :maxdepth: 1 - :caption: Reference: + :maxdepth: 2 - reference/hadar.workflow.rst - reference/hadar.optimizer.rst - reference/hadar.analyzer.rst - reference/hadar.viewer.rst + reference/modules .. toctree:: - :maxdepth: 1 - :caption: Legal Terms + :maxdepth: 2 terms/terms.rst + diff --git a/docs/source/mathematics/index.rst b/docs/source/mathematics/index.rst new file mode 100644 index 0000000..a998079 --- /dev/null +++ b/docs/source/mathematics/index.rst @@ -0,0 +1,8 @@ +============ +Mathematics +============ + +.. toctree:: + :maxdepth: 2 + + linear-model \ No newline at end of file diff --git a/docs/source/reference/modules.rst b/docs/source/reference/modules.rst index 6dd8cbd..cc5d82a 100644 --- a/docs/source/reference/modules.rst +++ b/docs/source/reference/modules.rst @@ -1,5 +1,5 @@ -hadar -===== +Reference +========= .. toctree:: :maxdepth: 4 diff --git a/docs/source/terms/terms.rst b/docs/source/terms/terms.rst index 31928dd..819a7fb 100644 --- a/docs/source/terms/terms.rst +++ b/docs/source/terms/terms.rst @@ -1,3 +1,5 @@ +.. _terms: + Legal Terms =========== From 5f0a2f0f0070d988ab6f1bbd5f1982ee4f3438d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Wed, 16 Sep 2020 15:42:45 +0200 Subject: [PATCH 23/38] add notebook into sphinx docs --- docs/requirements.txt | 4 +-- docs/source/architecture/index.rst | 2 ++ docs/source/conf.py | 1 + docs/source/dev-guide/index.rst | 8 +++-- .../Analyze Result/Analyze Result.ipynb | 3 ++ .../Begin Stochastic/Begin Stochastic.ipynb | 3 ++ .../examples}/Begin Stochastic/eolien.csv | 0 .../examples}/Begin Stochastic/figure.png | Bin .../source/examples}/Begin Stochastic/gas.csv | 0 .../examples}/Begin Stochastic/load_A.csv | 0 .../examples}/Begin Stochastic/load_B.csv | 0 .../examples}/Begin Stochastic/load_D.csv | 0 .../Begin Stochastic/monte-carlo.png | Bin .../examples}/Begin Stochastic/nuclear.csv | 0 .../Cost and Prioritization.ipynb | 0 .../FR-DE Adequacy/FR-DE Adequacy.ipynb | 0 .../source/examples}/FR-DE Adequacy/de.csv | 0 .../source/examples}/FR-DE Adequacy/fr.csv | 0 .../examples/Get Started/Get Started.ipynb | 3 ++ .../examples}/Get Started/figure.drawio | 0 .../source/examples}/Get Started/figure.png | Bin .../Multi-Energies/Multi-Energies.ipynb | 3 ++ .../examples}/Multi-Energies/figure.drawio | 0 .../examples}/Multi-Energies/figure.png | Bin .../examples}/Network Investment/12scn.prof | Bin .../Network Investment.ipynb | 0 .../source/examples}/Network Investment/a.csv | 0 .../source/examples}/Network Investment/b.csv | 0 .../source/examples}/Network Investment/c.csv | 0 .../source/examples}/Network Investment/d.csv | 0 .../Network Investment/figure.drawio | 0 .../examples}/Network Investment/figure.png | Bin .../examples}/Network Investment/solar.csv | 0 docs/source/examples/Storage/Storage.ipynb | 3 ++ .../source/examples}/Storage/figure.drawio | 0 .../source/examples}/Storage/figure.png | Bin .../Worflow Advenced/Workflow Advenced.ipynb | 3 ++ docs/source/examples/Workflow/Workflow.ipynb | 3 ++ .../source/examples}/Workflow/shuffler.png | Bin docs/source/examples/index.rst | 31 ++++++++++++++++++ .../source/examples}/requirements.txt | 0 {examples => docs/source/examples}/utils.py | 0 docs/source/index.rst | 15 +++++---- docs/source/mathematics/index.rst | 2 ++ docs/source/reference/modules.rst | 2 ++ examples | 1 + examples/Analyze Result/Analyze Result.ipynb | 3 -- .../Begin Stochastic/Begin Stochastic.ipynb | 3 -- examples/Get Started/Get Started.ipynb | 3 -- examples/Multi-Energies/Multi-Energies.ipynb | 3 -- examples/Storage/Storage.ipynb | 3 -- .../Worflow Advenced/Workflow Advenced.ipynb | 3 -- examples/Workflow/Workflow.ipynb | 3 -- 53 files changed, 76 insertions(+), 32 deletions(-) create mode 100644 docs/source/examples/Analyze Result/Analyze Result.ipynb create mode 100644 docs/source/examples/Begin Stochastic/Begin Stochastic.ipynb rename {examples => docs/source/examples}/Begin Stochastic/eolien.csv (100%) rename {examples => docs/source/examples}/Begin Stochastic/figure.png (100%) rename {examples => docs/source/examples}/Begin Stochastic/gas.csv (100%) rename {examples => docs/source/examples}/Begin Stochastic/load_A.csv (100%) rename {examples => docs/source/examples}/Begin Stochastic/load_B.csv (100%) rename {examples => docs/source/examples}/Begin Stochastic/load_D.csv (100%) rename {examples => docs/source/examples}/Begin Stochastic/monte-carlo.png (100%) rename {examples => docs/source/examples}/Begin Stochastic/nuclear.csv (100%) rename {examples => docs/source/examples}/Cost and Prioritization/Cost and Prioritization.ipynb (100%) rename {examples => docs/source/examples}/FR-DE Adequacy/FR-DE Adequacy.ipynb (100%) rename {examples => docs/source/examples}/FR-DE Adequacy/de.csv (100%) rename {examples => docs/source/examples}/FR-DE Adequacy/fr.csv (100%) create mode 100644 docs/source/examples/Get Started/Get Started.ipynb rename {examples => docs/source/examples}/Get Started/figure.drawio (100%) rename {examples => docs/source/examples}/Get Started/figure.png (100%) create mode 100644 docs/source/examples/Multi-Energies/Multi-Energies.ipynb rename {examples => docs/source/examples}/Multi-Energies/figure.drawio (100%) rename {examples => docs/source/examples}/Multi-Energies/figure.png (100%) rename {examples => docs/source/examples}/Network Investment/12scn.prof (100%) rename {examples => docs/source/examples}/Network Investment/Network Investment.ipynb (100%) rename {examples => docs/source/examples}/Network Investment/a.csv (100%) rename {examples => docs/source/examples}/Network Investment/b.csv (100%) rename {examples => docs/source/examples}/Network Investment/c.csv (100%) rename {examples => docs/source/examples}/Network Investment/d.csv (100%) rename {examples => docs/source/examples}/Network Investment/figure.drawio (100%) rename {examples => docs/source/examples}/Network Investment/figure.png (100%) rename {examples => docs/source/examples}/Network Investment/solar.csv (100%) create mode 100644 docs/source/examples/Storage/Storage.ipynb rename {examples => docs/source/examples}/Storage/figure.drawio (100%) rename {examples => docs/source/examples}/Storage/figure.png (100%) create mode 100644 docs/source/examples/Worflow Advenced/Workflow Advenced.ipynb create mode 100644 docs/source/examples/Workflow/Workflow.ipynb rename {examples => docs/source/examples}/Workflow/shuffler.png (100%) create mode 100644 docs/source/examples/index.rst rename {examples => docs/source/examples}/requirements.txt (100%) rename {examples => docs/source/examples}/utils.py (100%) create mode 120000 examples delete mode 100644 examples/Analyze Result/Analyze Result.ipynb delete mode 100644 examples/Begin Stochastic/Begin Stochastic.ipynb delete mode 100644 examples/Get Started/Get Started.ipynb delete mode 100644 examples/Multi-Energies/Multi-Energies.ipynb delete mode 100644 examples/Storage/Storage.ipynb delete mode 100644 examples/Worflow Advenced/Workflow Advenced.ipynb delete mode 100644 examples/Workflow/Workflow.ipynb diff --git a/docs/requirements.txt b/docs/requirements.txt index c61bab7..2b8b32a 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ sphinx -sphinx-rtd-theme sphinx-autobuild -pydata-sphinx-theme \ No newline at end of file +pydata-sphinx-theme +nbsphinx \ No newline at end of file diff --git a/docs/source/architecture/index.rst b/docs/source/architecture/index.rst index 8d4acb7..4960f09 100644 --- a/docs/source/architecture/index.rst +++ b/docs/source/architecture/index.rst @@ -1,3 +1,5 @@ +.. _architecture: + ============ Architecture ============ diff --git a/docs/source/conf.py b/docs/source/conf.py index bf7dd3a..f2bbcad 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -57,6 +57,7 @@ def remove_version(req: str): extensions = [ 'sphinx_rtd_theme', 'sphinx.ext.autodoc', + 'nbsphinx' ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/source/dev-guide/index.rst b/docs/source/dev-guide/index.rst index 6f277fb..c11983a 100644 --- a/docs/source/dev-guide/index.rst +++ b/docs/source/dev-guide/index.rst @@ -1,6 +1,8 @@ -========= -Dev Guide -========= +.. _dev-guide: + +============ +Contributing +============ .. toctree:: diff --git a/docs/source/examples/Analyze Result/Analyze Result.ipynb b/docs/source/examples/Analyze Result/Analyze Result.ipynb new file mode 100644 index 0000000..58eecb3 --- /dev/null +++ b/docs/source/examples/Analyze Result/Analyze Result.ipynb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8cae85d880c00ef59bb8e023f34b0bda23ade81d6f0c62c6717ed39bbfd4b948 +size 3673592 diff --git a/docs/source/examples/Begin Stochastic/Begin Stochastic.ipynb b/docs/source/examples/Begin Stochastic/Begin Stochastic.ipynb new file mode 100644 index 0000000..a8d6fac --- /dev/null +++ b/docs/source/examples/Begin Stochastic/Begin Stochastic.ipynb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:26bac8dc59080aa6bc6e261eb0a50d5c90ee8ccda4d9a795f22456f46b374c41 +size 4293555 diff --git a/examples/Begin Stochastic/eolien.csv b/docs/source/examples/Begin Stochastic/eolien.csv similarity index 100% rename from examples/Begin Stochastic/eolien.csv rename to docs/source/examples/Begin Stochastic/eolien.csv diff --git a/examples/Begin Stochastic/figure.png b/docs/source/examples/Begin Stochastic/figure.png similarity index 100% rename from examples/Begin Stochastic/figure.png rename to docs/source/examples/Begin Stochastic/figure.png diff --git a/examples/Begin Stochastic/gas.csv b/docs/source/examples/Begin Stochastic/gas.csv similarity index 100% rename from examples/Begin Stochastic/gas.csv rename to docs/source/examples/Begin Stochastic/gas.csv diff --git a/examples/Begin Stochastic/load_A.csv b/docs/source/examples/Begin Stochastic/load_A.csv similarity index 100% rename from examples/Begin Stochastic/load_A.csv rename to docs/source/examples/Begin Stochastic/load_A.csv diff --git a/examples/Begin Stochastic/load_B.csv b/docs/source/examples/Begin Stochastic/load_B.csv similarity index 100% rename from examples/Begin Stochastic/load_B.csv rename to docs/source/examples/Begin Stochastic/load_B.csv diff --git a/examples/Begin Stochastic/load_D.csv b/docs/source/examples/Begin Stochastic/load_D.csv similarity index 100% rename from examples/Begin Stochastic/load_D.csv rename to docs/source/examples/Begin Stochastic/load_D.csv diff --git a/examples/Begin Stochastic/monte-carlo.png b/docs/source/examples/Begin Stochastic/monte-carlo.png similarity index 100% rename from examples/Begin Stochastic/monte-carlo.png rename to docs/source/examples/Begin Stochastic/monte-carlo.png diff --git a/examples/Begin Stochastic/nuclear.csv b/docs/source/examples/Begin Stochastic/nuclear.csv similarity index 100% rename from examples/Begin Stochastic/nuclear.csv rename to docs/source/examples/Begin Stochastic/nuclear.csv diff --git a/examples/Cost and Prioritization/Cost and Prioritization.ipynb b/docs/source/examples/Cost and Prioritization/Cost and Prioritization.ipynb similarity index 100% rename from examples/Cost and Prioritization/Cost and Prioritization.ipynb rename to docs/source/examples/Cost and Prioritization/Cost and Prioritization.ipynb diff --git a/examples/FR-DE Adequacy/FR-DE Adequacy.ipynb b/docs/source/examples/FR-DE Adequacy/FR-DE Adequacy.ipynb similarity index 100% rename from examples/FR-DE Adequacy/FR-DE Adequacy.ipynb rename to docs/source/examples/FR-DE Adequacy/FR-DE Adequacy.ipynb diff --git a/examples/FR-DE Adequacy/de.csv b/docs/source/examples/FR-DE Adequacy/de.csv similarity index 100% rename from examples/FR-DE Adequacy/de.csv rename to docs/source/examples/FR-DE Adequacy/de.csv diff --git a/examples/FR-DE Adequacy/fr.csv b/docs/source/examples/FR-DE Adequacy/fr.csv similarity index 100% rename from examples/FR-DE Adequacy/fr.csv rename to docs/source/examples/FR-DE Adequacy/fr.csv diff --git a/docs/source/examples/Get Started/Get Started.ipynb b/docs/source/examples/Get Started/Get Started.ipynb new file mode 100644 index 0000000..44b653d --- /dev/null +++ b/docs/source/examples/Get Started/Get Started.ipynb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f594860c2e5a6be645825aa98c0ffa24385f1d5fb0d7bb8b6ab1ec29ae4fa04e +size 3706332 diff --git a/examples/Get Started/figure.drawio b/docs/source/examples/Get Started/figure.drawio similarity index 100% rename from examples/Get Started/figure.drawio rename to docs/source/examples/Get Started/figure.drawio diff --git a/examples/Get Started/figure.png b/docs/source/examples/Get Started/figure.png similarity index 100% rename from examples/Get Started/figure.png rename to docs/source/examples/Get Started/figure.png diff --git a/docs/source/examples/Multi-Energies/Multi-Energies.ipynb b/docs/source/examples/Multi-Energies/Multi-Energies.ipynb new file mode 100644 index 0000000..dee126e --- /dev/null +++ b/docs/source/examples/Multi-Energies/Multi-Energies.ipynb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3c4a5e346e59947c6f824d2913cc6b7a65d9ffcebfd0174363c92f03852ac1a +size 3563308 diff --git a/examples/Multi-Energies/figure.drawio b/docs/source/examples/Multi-Energies/figure.drawio similarity index 100% rename from examples/Multi-Energies/figure.drawio rename to docs/source/examples/Multi-Energies/figure.drawio diff --git a/examples/Multi-Energies/figure.png b/docs/source/examples/Multi-Energies/figure.png similarity index 100% rename from examples/Multi-Energies/figure.png rename to docs/source/examples/Multi-Energies/figure.png diff --git a/examples/Network Investment/12scn.prof b/docs/source/examples/Network Investment/12scn.prof similarity index 100% rename from examples/Network Investment/12scn.prof rename to docs/source/examples/Network Investment/12scn.prof diff --git a/examples/Network Investment/Network Investment.ipynb b/docs/source/examples/Network Investment/Network Investment.ipynb similarity index 100% rename from examples/Network Investment/Network Investment.ipynb rename to docs/source/examples/Network Investment/Network Investment.ipynb diff --git a/examples/Network Investment/a.csv b/docs/source/examples/Network Investment/a.csv similarity index 100% rename from examples/Network Investment/a.csv rename to docs/source/examples/Network Investment/a.csv diff --git a/examples/Network Investment/b.csv b/docs/source/examples/Network Investment/b.csv similarity index 100% rename from examples/Network Investment/b.csv rename to docs/source/examples/Network Investment/b.csv diff --git a/examples/Network Investment/c.csv b/docs/source/examples/Network Investment/c.csv similarity index 100% rename from examples/Network Investment/c.csv rename to docs/source/examples/Network Investment/c.csv diff --git a/examples/Network Investment/d.csv b/docs/source/examples/Network Investment/d.csv similarity index 100% rename from examples/Network Investment/d.csv rename to docs/source/examples/Network Investment/d.csv diff --git a/examples/Network Investment/figure.drawio b/docs/source/examples/Network Investment/figure.drawio similarity index 100% rename from examples/Network Investment/figure.drawio rename to docs/source/examples/Network Investment/figure.drawio diff --git a/examples/Network Investment/figure.png b/docs/source/examples/Network Investment/figure.png similarity index 100% rename from examples/Network Investment/figure.png rename to docs/source/examples/Network Investment/figure.png diff --git a/examples/Network Investment/solar.csv b/docs/source/examples/Network Investment/solar.csv similarity index 100% rename from examples/Network Investment/solar.csv rename to docs/source/examples/Network Investment/solar.csv diff --git a/docs/source/examples/Storage/Storage.ipynb b/docs/source/examples/Storage/Storage.ipynb new file mode 100644 index 0000000..bf9cea0 --- /dev/null +++ b/docs/source/examples/Storage/Storage.ipynb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f07e08962be256a19f5e5ee04cf2bde1c7ed36c0141b96a9638d16b2f9e93e7 +size 431536 diff --git a/examples/Storage/figure.drawio b/docs/source/examples/Storage/figure.drawio similarity index 100% rename from examples/Storage/figure.drawio rename to docs/source/examples/Storage/figure.drawio diff --git a/examples/Storage/figure.png b/docs/source/examples/Storage/figure.png similarity index 100% rename from examples/Storage/figure.png rename to docs/source/examples/Storage/figure.png diff --git a/docs/source/examples/Worflow Advenced/Workflow Advenced.ipynb b/docs/source/examples/Worflow Advenced/Workflow Advenced.ipynb new file mode 100644 index 0000000..b661a8f --- /dev/null +++ b/docs/source/examples/Worflow Advenced/Workflow Advenced.ipynb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2cdcae0bcea75ab27dd735bbf509c177f6e74c5a748131647abccd986ef44c2f +size 3679299 diff --git a/docs/source/examples/Workflow/Workflow.ipynb b/docs/source/examples/Workflow/Workflow.ipynb new file mode 100644 index 0000000..db4cac7 --- /dev/null +++ b/docs/source/examples/Workflow/Workflow.ipynb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b64b0d915072816399b80a587a2b69abefe9d227beeee6555444993edffd4da4 +size 4046502 diff --git a/examples/Workflow/shuffler.png b/docs/source/examples/Workflow/shuffler.png similarity index 100% rename from examples/Workflow/shuffler.png rename to docs/source/examples/Workflow/shuffler.png diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst new file mode 100644 index 0000000..6b429d6 --- /dev/null +++ b/docs/source/examples/index.rst @@ -0,0 +1,31 @@ +.. _tutorials: + +========= +Tutorials +========= + + +.. toctree:: + :maxdepth: 1 + :caption: Basic + + Get Started/Get Started + Cost and Prioritization/Cost and Prioritization + FR-DE Adequacy/FR-DE Adequacy + Analyze Result/Analyze Result + Network Investment/Network Investment + +.. toctree:: + :maxdepth: 1 + :caption: Stochastic + + Begin Stochastic/Begin Stochastic + Workflow/Workflow + Workflow Advanced/Workflow Advanced + +.. toctree:: + :maxdepth: 1 + :caption: Advanced + + Storage/Storage + Multi-Energies/Multi-Energies \ No newline at end of file diff --git a/examples/requirements.txt b/docs/source/examples/requirements.txt similarity index 100% rename from examples/requirements.txt rename to docs/source/examples/requirements.txt diff --git a/examples/utils.py b/docs/source/examples/utils.py similarity index 100% rename from examples/utils.py rename to docs/source/examples/utils.py diff --git a/docs/source/index.rst b/docs/source/index.rst index 02fba22..953b0cf 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -18,16 +18,20 @@ You are in the technical documentation. * If you want to discover Hadar and the project, please go to https://www.hadar-simulator.org for an overview -* If you want to start using Hadar, you can begin with `tutorials `_ +* If you want to start using Hadar, you can begin with :ref:`Tutorials ` -* If you want to understand Hadar engine, see **Architecture** +* If you want to understand Hadar engine, see :ref:`Architecture ` -* If you want to look at a method or object behavior search inside **References** +* If you want to look at a method or object behavior search inside :ref:`Reference ` -* If you want to help us coding Hadar, please read **Dev Guide** before. +* If you want to help us coding Hadar, please read :ref:`Contributing ` before. -* If you want to see Mathematics model used in Hadar, go to **Mathematics**. +* If you want to see Mathematics model used in Hadar, go to :ref:`Mathematics `. +.. toctree:: + :maxdepth: 2 + + examples/index.rst .. toctree:: :maxdepth: 2 @@ -54,4 +58,3 @@ You are in the technical documentation. :maxdepth: 2 terms/terms.rst - diff --git a/docs/source/mathematics/index.rst b/docs/source/mathematics/index.rst index a998079..180fc6b 100644 --- a/docs/source/mathematics/index.rst +++ b/docs/source/mathematics/index.rst @@ -1,3 +1,5 @@ +.. _mathematics: + ============ Mathematics ============ diff --git a/docs/source/reference/modules.rst b/docs/source/reference/modules.rst index cc5d82a..8a9b5e3 100644 --- a/docs/source/reference/modules.rst +++ b/docs/source/reference/modules.rst @@ -1,3 +1,5 @@ +.. _reference: + Reference ========= diff --git a/examples b/examples new file mode 120000 index 0000000..9019710 --- /dev/null +++ b/examples @@ -0,0 +1 @@ +docs/source/examples \ No newline at end of file diff --git a/examples/Analyze Result/Analyze Result.ipynb b/examples/Analyze Result/Analyze Result.ipynb deleted file mode 100644 index b56b70a..0000000 --- a/examples/Analyze Result/Analyze Result.ipynb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:dacfe9b0d62bca5b684a9a11e6df484dce7aa1bb8c3a9a4b643bacbf920c7349 -size 3673391 diff --git a/examples/Begin Stochastic/Begin Stochastic.ipynb b/examples/Begin Stochastic/Begin Stochastic.ipynb deleted file mode 100644 index 78aaf57..0000000 --- a/examples/Begin Stochastic/Begin Stochastic.ipynb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c69502d2df96e517fce85c8ed75f70f1c2486f112b529d4aa586e8cc8f972504 -size 4293528 diff --git a/examples/Get Started/Get Started.ipynb b/examples/Get Started/Get Started.ipynb deleted file mode 100644 index 7b55791..0000000 --- a/examples/Get Started/Get Started.ipynb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d7ba7667160ba07167f618179d0cb11709b98a0fc850f5b9b17067646e3b62c2 -size 3706311 diff --git a/examples/Multi-Energies/Multi-Energies.ipynb b/examples/Multi-Energies/Multi-Energies.ipynb deleted file mode 100644 index 3acbadd..0000000 --- a/examples/Multi-Energies/Multi-Energies.ipynb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a6703aead548df1bf7ab37dd941ef97552fb77f8a67189e25b13b04f298a825d -size 3563273 diff --git a/examples/Storage/Storage.ipynb b/examples/Storage/Storage.ipynb deleted file mode 100644 index ba165eb..0000000 --- a/examples/Storage/Storage.ipynb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:33de22c622c60a3f7bb2a32c717b149841c4ebe0917e04e73d2d843c8dd99ba1 -size 431508 diff --git a/examples/Worflow Advenced/Workflow Advenced.ipynb b/examples/Worflow Advenced/Workflow Advenced.ipynb deleted file mode 100644 index af175db..0000000 --- a/examples/Worflow Advenced/Workflow Advenced.ipynb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ddc3ed0991a77d3977af8df544ac4141646076fc36ccbc17b7eb6e8c941c84d0 -size 3679261 diff --git a/examples/Workflow/Workflow.ipynb b/examples/Workflow/Workflow.ipynb deleted file mode 100644 index 709fda3..0000000 --- a/examples/Workflow/Workflow.ipynb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3a6819280b7f4d679fa88716e078fc9453bebcfe30cc13a6870386193109e5a2 -size 4046483 From 99e8f3da81d6f16cb994da6a8efd3eed8ea04da1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Wed, 16 Sep 2020 16:20:02 +0200 Subject: [PATCH 24/38] fix readthedoc issue [skip ci] --- docs/requirements.txt | 4 +++- docs/source/conf.py | 3 ++- docs/source/examples/Worflow Advenced/Workflow Advenced.ipynb | 3 --- .../source/examples/Workflow Advanced/Workflow Advanced.ipynb | 3 +++ 4 files changed, 8 insertions(+), 5 deletions(-) delete mode 100644 docs/source/examples/Worflow Advenced/Workflow Advenced.ipynb create mode 100644 docs/source/examples/Workflow Advanced/Workflow Advanced.ipynb diff --git a/docs/requirements.txt b/docs/requirements.txt index 2b8b32a..fd0f19e 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,6 @@ sphinx sphinx-autobuild pydata-sphinx-theme -nbsphinx \ No newline at end of file +nbsphinx +Pygments==2.6.1 +jupyter \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index f2bbcad..820dacf 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -57,7 +57,8 @@ def remove_version(req: str): extensions = [ 'sphinx_rtd_theme', 'sphinx.ext.autodoc', - 'nbsphinx' + 'nbsphinx', + 'IPython.sphinxext.ipython_console_highlighting' ] # Add any paths that contain templates here, relative to this directory. diff --git a/docs/source/examples/Worflow Advenced/Workflow Advenced.ipynb b/docs/source/examples/Worflow Advenced/Workflow Advenced.ipynb deleted file mode 100644 index b661a8f..0000000 --- a/docs/source/examples/Worflow Advenced/Workflow Advenced.ipynb +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2cdcae0bcea75ab27dd735bbf509c177f6e74c5a748131647abccd986ef44c2f -size 3679299 diff --git a/docs/source/examples/Workflow Advanced/Workflow Advanced.ipynb b/docs/source/examples/Workflow Advanced/Workflow Advanced.ipynb new file mode 100644 index 0000000..4343c62 --- /dev/null +++ b/docs/source/examples/Workflow Advanced/Workflow Advanced.ipynb @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:62fc1e7fc01385592990e1c1fb086e7d7abffa1e6fd6a99260e8dbcb2a7e05cc +size 3679299 From 0da5b7b149842c3dfef4091a9f8e32d7ba828648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Wed, 16 Sep 2020 17:01:42 +0200 Subject: [PATCH 25/38] fix readthedoc issue [skip ci] --- docs/source/examples/Get Started/Get Started.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/examples/Get Started/Get Started.ipynb b/docs/source/examples/Get Started/Get Started.ipynb index 44b653d..ddefbc3 100644 --- a/docs/source/examples/Get Started/Get Started.ipynb +++ b/docs/source/examples/Get Started/Get Started.ipynb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f594860c2e5a6be645825aa98c0ffa24385f1d5fb0d7bb8b6ab1ec29ae4fa04e -size 3706332 +oid sha256:4cbde2c3f7e3e8c08660d310d39321a9f6760e2f98206ebe889b12bf588dffd9 +size 3706369 From ece7142865dcb0eae6c15fcffc62fe9bdb97f704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Wed, 16 Sep 2020 17:27:09 +0200 Subject: [PATCH 26/38] fix readthedoc issue [skip ci] --- docs/source/examples/Get Started/Get Started.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/examples/Get Started/Get Started.ipynb b/docs/source/examples/Get Started/Get Started.ipynb index ddefbc3..e2d794d 100644 --- a/docs/source/examples/Get Started/Get Started.ipynb +++ b/docs/source/examples/Get Started/Get Started.ipynb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4cbde2c3f7e3e8c08660d310d39321a9f6760e2f98206ebe889b12bf588dffd9 -size 3706369 +oid sha256:55bf08b32d883e291f4e4c259f64f684064f2bfefda006db5837e39b1677ec5c +size 3706406 From 6ed9c333ddccbe89d06b389bbfe2872bd076bd87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Wed, 16 Sep 2020 18:04:20 +0200 Subject: [PATCH 27/38] fix readthedoc issue [skip ci] --- .readthedocs.yml | 11 +++++++++++ docs/source/conf.py | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 .readthedocs.yml diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..2dbd161 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,11 @@ +version: 2 +build: + image: latest +python: + version: 3 + install: + - requirements: doc/requirements.txt + - method: pip + path: . + system_packages: true +formats: all \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index 820dacf..3a92489 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -58,7 +58,8 @@ def remove_version(req: str): 'sphinx_rtd_theme', 'sphinx.ext.autodoc', 'nbsphinx', - 'IPython.sphinxext.ipython_console_highlighting' + 'IPython.sphinxext.ipython_console_highlighting', + 'sphinx.ext.mathjax', ] # Add any paths that contain templates here, relative to this directory. From 817a7b84e9b7be879b080071be7c7b98290fa691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Wed, 16 Sep 2020 18:05:14 +0200 Subject: [PATCH 28/38] fix readthedoc issue [skip ci] --- .readthedocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 2dbd161..497d25c 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,7 +4,7 @@ build: python: version: 3 install: - - requirements: doc/requirements.txt + - requirements: docs/requirements.txt - method: pip path: . system_packages: true From 12df8b89b4685f0b8dd978b382831a1168d4bcb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Wed, 16 Sep 2020 18:16:18 +0200 Subject: [PATCH 29/38] fix readthedoc issue [skip ci] --- .readthedocs.yml | 1 + docs/source/conf.py | 21 --------------------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index 497d25c..2d001fa 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -4,6 +4,7 @@ build: python: version: 3 install: + - requirements: requirements.txt - requirements: docs/requirements.txt - method: pip path: . diff --git a/docs/source/conf.py b/docs/source/conf.py index 3a92489..be0cf97 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,31 +12,10 @@ # import os import sys -from unittest.mock import Mock sys.path.insert(0, os.path.abspath('../..')) -def remove_version(req: str): - """ - Remove version in string like 'package==4.3.1' or 'package>=8.4.7' - :param req: - :return: - """ - for sep in ['>=', '==']: - if sep in req: - return req.split(sep)[0] - return req - - -with open('../../requirements.txt') as f: - imports = [remove_version(r) for r in f.read().split('\n')] +\ - ['numpy.random', 'ortools.linear_solver.pywraplp', 'progress.bar', 'progress.spinner', - 'plotly.graph_objects', 'matplotlib.cm', 'requests.exceptions'] - for i in imports: - sys.modules[i] = Mock() -print(imports) - import hadar # -- Project information ----------------------------------------------------- From cf41926201f9b5afc9bd74e8f527aa8ace9419f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Thu, 17 Sep 2020 11:20:06 +0200 Subject: [PATCH 30/38] fix readthedoc issue [skip ci] --- docs/source/examples/Get Started/Get Started.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/examples/Get Started/Get Started.ipynb b/docs/source/examples/Get Started/Get Started.ipynb index e2d794d..c5cce30 100644 --- a/docs/source/examples/Get Started/Get Started.ipynb +++ b/docs/source/examples/Get Started/Get Started.ipynb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:55bf08b32d883e291f4e4c259f64f684064f2bfefda006db5837e39b1677ec5c -size 3706406 +oid sha256:c155c19d09b80c30b562a168034986c3b9ecdbb55ae05f07666e0cbb5dd11b15 +size 3706505 From d9743b095bb65b2102d2d49a67a1ad2b8e212bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Thu, 17 Sep 2020 11:41:19 +0200 Subject: [PATCH 31/38] fix readthedoc issue [skip ci] --- docs/source/examples/Storage/Storage.ipynb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/examples/Storage/Storage.ipynb b/docs/source/examples/Storage/Storage.ipynb index bf9cea0..d7bfc3a 100644 --- a/docs/source/examples/Storage/Storage.ipynb +++ b/docs/source/examples/Storage/Storage.ipynb @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0f07e08962be256a19f5e5ee04cf2bde1c7ed36c0141b96a9638d16b2f9e93e7 -size 431536 +oid sha256:333ba76fad5f8ca2548e933bb6513a08ec766c845c67ec9b519ebf457d9d80cb +size 431600 From 6992fda238bec307f0da9f8531d166c8f9ebdec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Thu, 17 Sep 2020 13:23:08 +0200 Subject: [PATCH 32/38] fix readthedoc issue [skip ci] --- .readthedocs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.readthedocs.yml b/.readthedocs.yml index 2d001fa..3902bdc 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,6 +1,8 @@ version: 2 build: image: latest +sphinx: + builder: html python: version: 3 install: From 1f13c5ba24dc9ecb0b634c5874022ca26183cb57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Thu, 17 Sep 2020 13:36:32 +0200 Subject: [PATCH 33/38] fix readthedoc issue [skip ci] --- docs/source/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index be0cf97..596f453 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -34,7 +34,6 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx_rtd_theme', 'sphinx.ext.autodoc', 'nbsphinx', 'IPython.sphinxext.ipython_console_highlighting', From 53b4be82128281d70be233ddd14fac941a0df50e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Thu, 17 Sep 2020 15:13:18 +0200 Subject: [PATCH 34/38] remove examples outside docs. Export examples in .rst inside sphinx --- .../Analyze Result/Analyze Result.rst | 967 ++++++++++++++++++ .../Begin Stochastic/Begin Stochastic.rst | 507 +++++++++ .../Cost and Prioritization.rst | 426 ++++++++ .../FR-DE Adequacy/FR-DE Adequacy.rst | 431 ++++++++ .../examples/Get Started/Get Started.rst | 363 +++++++ .../Multi-Energies/Multi-Energies.rst | 262 +++++ .../Network Investment/Network Investment.rst | 647 ++++++++++++ docs/source/examples/Storage/Storage.rst | 534 ++++++++++ .../Workflow Advanced/Workflow Advanced.rst | 678 ++++++++++++ docs/source/examples/Workflow/Workflow.rst | 423 ++++++++ examples | 1 - .../Analyze Result/Analyze Result.ipynb | 0 .../Begin Stochastic/Begin Stochastic.ipynb | 0 .../Begin Stochastic/eolien.csv | 0 .../Begin Stochastic/gas.csv | 0 .../Begin Stochastic/load_A.csv | 0 .../Begin Stochastic/load_B.csv | 0 .../Begin Stochastic/load_D.csv | 0 .../Begin Stochastic/nuclear.csv | 0 .../Cost and Prioritization.ipynb | 0 .../FR-DE Adequacy/FR-DE Adequacy.ipynb | 0 .../FR-DE Adequacy/de.csv | 0 .../FR-DE Adequacy/fr.csv | 0 .../Get Started/Get Started.ipynb | 0 .../Get Started/figure.drawio | 0 .../Multi-Energies/Multi-Energies.ipynb | 0 .../Multi-Energies/figure.drawio | 0 .../Network Investment/12scn.prof | Bin .../Network Investment.ipynb | 0 .../Network Investment/a.csv | 0 .../Network Investment/b.csv | 0 .../Network Investment/c.csv | 0 .../Network Investment/d.csv | 0 .../Network Investment/figure.drawio | 0 .../Network Investment/solar.csv | 0 .../Storage/Storage.ipynb | 0 .../Storage/figure.drawio | 0 .../Workflow Advanced/Workflow Advanced.ipynb | 0 .../Workflow/Workflow.ipynb | 0 .../examples => examples}/requirements.txt | 0 {docs/source/examples => examples}/utils.py | 10 +- 41 files changed, 5243 insertions(+), 6 deletions(-) create mode 100644 docs/source/examples/Analyze Result/Analyze Result.rst create mode 100644 docs/source/examples/Begin Stochastic/Begin Stochastic.rst create mode 100644 docs/source/examples/Cost and Prioritization/Cost and Prioritization.rst create mode 100644 docs/source/examples/FR-DE Adequacy/FR-DE Adequacy.rst create mode 100644 docs/source/examples/Get Started/Get Started.rst create mode 100644 docs/source/examples/Multi-Energies/Multi-Energies.rst create mode 100644 docs/source/examples/Network Investment/Network Investment.rst create mode 100644 docs/source/examples/Storage/Storage.rst create mode 100644 docs/source/examples/Workflow Advanced/Workflow Advanced.rst create mode 100644 docs/source/examples/Workflow/Workflow.rst delete mode 120000 examples rename {docs/source/examples => examples}/Analyze Result/Analyze Result.ipynb (100%) rename {docs/source/examples => examples}/Begin Stochastic/Begin Stochastic.ipynb (100%) rename {docs/source/examples => examples}/Begin Stochastic/eolien.csv (100%) rename {docs/source/examples => examples}/Begin Stochastic/gas.csv (100%) rename {docs/source/examples => examples}/Begin Stochastic/load_A.csv (100%) rename {docs/source/examples => examples}/Begin Stochastic/load_B.csv (100%) rename {docs/source/examples => examples}/Begin Stochastic/load_D.csv (100%) rename {docs/source/examples => examples}/Begin Stochastic/nuclear.csv (100%) rename {docs/source/examples => examples}/Cost and Prioritization/Cost and Prioritization.ipynb (100%) rename {docs/source/examples => examples}/FR-DE Adequacy/FR-DE Adequacy.ipynb (100%) rename {docs/source/examples => examples}/FR-DE Adequacy/de.csv (100%) rename {docs/source/examples => examples}/FR-DE Adequacy/fr.csv (100%) rename {docs/source/examples => examples}/Get Started/Get Started.ipynb (100%) rename {docs/source/examples => examples}/Get Started/figure.drawio (100%) rename {docs/source/examples => examples}/Multi-Energies/Multi-Energies.ipynb (100%) rename {docs/source/examples => examples}/Multi-Energies/figure.drawio (100%) rename {docs/source/examples => examples}/Network Investment/12scn.prof (100%) rename {docs/source/examples => examples}/Network Investment/Network Investment.ipynb (100%) rename {docs/source/examples => examples}/Network Investment/a.csv (100%) rename {docs/source/examples => examples}/Network Investment/b.csv (100%) rename {docs/source/examples => examples}/Network Investment/c.csv (100%) rename {docs/source/examples => examples}/Network Investment/d.csv (100%) rename {docs/source/examples => examples}/Network Investment/figure.drawio (100%) rename {docs/source/examples => examples}/Network Investment/solar.csv (100%) rename {docs/source/examples => examples}/Storage/Storage.ipynb (100%) rename {docs/source/examples => examples}/Storage/figure.drawio (100%) rename {docs/source/examples => examples}/Workflow Advanced/Workflow Advanced.ipynb (100%) rename {docs/source/examples => examples}/Workflow/Workflow.ipynb (100%) rename {docs/source/examples => examples}/requirements.txt (100%) rename {docs/source/examples => examples}/utils.py (94%) diff --git a/docs/source/examples/Analyze Result/Analyze Result.rst b/docs/source/examples/Analyze Result/Analyze Result.rst new file mode 100644 index 0000000..3ce924f --- /dev/null +++ b/docs/source/examples/Analyze Result/Analyze Result.rst @@ -0,0 +1,967 @@ +Analyze Result +============== + +In this example, you learn to use ``ResultAnalyzer``. You has already +use it in preivous example to instanciate plotting: +``agg = hd.ResultAnalyzer(study, result)`` + +Let’s begin by build little study with two nodes (A and B) both has a +sinus-like load from 1500 to 500. Node A has a constant nuclear plan, +node B has eolien with linear random. + +.. code:: ipython3 + + import hadar as hd + import numpy as np + import pandas as pd + +.. code:: ipython3 + + t = np.linspace(0, np.pi * 14, 168) + load = 1000 + np.sin(t) * 500 + eolien = np.random.rand(t.size) * 1000 + +.. code:: ipython3 + + study = hd.Study(horizon=t.size, nb_scn=1)\ + .network()\ + .node('a')\ + .consumption(name='load', cost=10 ** 6, quantity=load)\ + .production(name='nuclear', cost=100, quantity=1500)\ + .node('b')\ + .consumption(name='load', cost=10 ** 6, quantity=load)\ + .production(name='eolien', cost=50, quantity=eolien)\ + .link(src='a', dest='b', cost=5, quantity=2000)\ + .link(src='b', dest='a', cost=5, quantity=2000)\ + .build() + +.. code:: ipython3 + + opt = hd.LPOptimizer() + res = opt.solve(study) + +.. code:: ipython3 + + agg = hd.ResultAnalyzer(study=study, result=res) + +Low API +------- + +Analyzer provide a *low* api, that means result could not be +ready-to-use, but it’s a very flexible way to analyze data. Low API +enable to thinks: - set order. data has for level : node, element, scn +and time. Low API can organize for your these level - filtering: for +each level you can apply a filter, to only select node ‘a’, or time from +10 to 35 timestep + +For examples you want select consumption named load other all node just +for 57 to 78 timestep + +.. code:: ipython3 + + agg.network().scn(0).consumption('load').node().time(slice(57, 78)) + + + + +.. raw:: html + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
askedcostgiven
nodet
a57.01320.5925051000000.01320.592505
58.01209.6501401000000.01209.650140
59.01084.2498411000000.01084.249841
60.0953.0394861000000.0953.039486
61.0825.0676331000000.0825.067633
62.0709.1595001000000.0709.159500
63.0613.3083691000000.0613.308369
64.0544.1243441000000.0544.124344
65.0506.3785081000000.0506.378508
66.0502.6738971000000.0502.673897
67.0533.2659901000000.0533.265990
68.0596.0450871000000.0596.045087
69.0686.6818051000000.0686.681805
70.0798.9256351000000.0798.925635
71.0925.0359961000000.0925.035996
72.01056.3160411000000.01056.316041
73.01183.7124061000000.01183.712406
74.01298.4395601000000.01298.439560
75.01392.5856651000000.01392.585665
76.01459.6581981000000.01459.658198
77.01495.0316891000000.01495.031689
b57.01320.5925051000000.0790.231774
58.01209.6501401000000.0351.005132
59.01084.2498411000000.0485.779325
60.0953.0394861000000.0953.039486
61.0825.0676331000000.0825.067633
62.0709.1595001000000.0709.159500
63.0613.3083691000000.0613.308369
64.0544.1243441000000.0544.124344
65.0506.3785081000000.0506.378508
66.0502.6738971000000.0502.673897
67.0533.2659901000000.0533.265990
68.0596.0450871000000.0596.045087
69.0686.6818051000000.0686.681805
70.0798.9256351000000.0798.925635
71.0925.0359961000000.0925.035996
72.01056.3160411000000.0933.836811
73.01183.7124061000000.01033.211070
74.01298.4395601000000.0601.396040
75.01392.5856651000000.0832.053023
76.01459.6581981000000.0439.140553
77.01495.0316891000000.0451.215115
+
+ + + +**TIP** If filter return only one element, set it at first. First +indexes with one element are removed to avoir useless indexes. + +Another example: Analyze all production first 24 timestep + +.. code:: ipython3 + + agg.network().scn(0).node().production().time(slice(0,24)) + + + + +.. raw:: html + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
availcostused
nodenamet
anuclear0.01500.000000100.01500.000000
1.01500.000000100.01500.000000
2.01500.000000100.01500.000000
3.01500.000000100.01500.000000
4.01500.000000100.01500.000000
5.01500.000000100.01500.000000
6.01500.000000100.01500.000000
7.01500.000000100.01500.000000
8.01500.000000100.01500.000000
9.01500.000000100.01500.000000
10.01500.000000100.01500.000000
11.01500.000000100.01500.000000
12.01500.000000100.01388.655756
13.01500.000000100.01376.698459
14.01500.000000100.01157.759171
15.01500.000000100.0318.599505
16.01500.000000100.0775.000819
17.01500.000000100.0937.348977
18.01500.000000100.032.439151
19.01500.000000100.0202.087813
20.01500.000000100.0806.885534
21.01500.000000100.0996.520417
22.01500.000000100.01264.946884
23.01500.000000100.01357.308648
beolien0.0313.56820050.0313.568200
1.0212.56292850.0212.562928
2.0761.04546450.0761.045464
3.0927.24438850.0927.244388
4.0529.82756550.0529.827565
5.0839.65565550.0839.655655
6.0103.95585350.0103.955853
7.091.08705450.091.087054
8.0171.10795750.0171.107957
9.0810.78047850.0810.780478
10.0409.85752150.0409.857521
11.0675.91007150.0675.910071
12.0592.53342150.0592.533421
13.0344.85242950.0344.852429
14.0323.35589150.0323.355891
15.0957.86317950.0957.863179
16.0346.70621450.0346.706214
17.090.17142250.090.171422
18.0967.95894750.0967.958947
19.0840.12273450.0840.122734
20.0343.18873150.0343.188731
21.0320.03031650.0320.030316
22.0265.21248450.0265.212484
23.0418.86060050.0418.860600
+
+ + + +To summrize low api, you can organize and filter data by network, +scenarios, time, node and elements on node. + +High API +-------- + +High API is ready to use data. It gives you a business oriented data +about adequacy. Today we have: - Get balance to compute net position on +a node - Get cost to compute cost on a node - Get Remain Available +Capacities + +.. code:: ipython3 + + import plotly.graph_objects as go + +.. code:: ipython3 + + def plot(y): + return go.Figure(go.Scatter(x=t, y=y.flatten())) + +.. code:: ipython3 + + data = agg.get_balance(node='a') # Compute net exchange for all scenario and timestep + plot(data) + + + +.. raw:: html + + + + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + data = agg.get_cost(node='b') # Compute cost for all scenario and timestep + plot(data) + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + data = agg.get_rac() # Compute Remain Available Capacities for all scenarios and timestep + plot(data) + + + +.. raw:: html + +
+ + +
+ +
+ + diff --git a/docs/source/examples/Begin Stochastic/Begin Stochastic.rst b/docs/source/examples/Begin Stochastic/Begin Stochastic.rst new file mode 100644 index 0000000..fe0feb1 --- /dev/null +++ b/docs/source/examples/Begin Stochastic/Begin Stochastic.rst @@ -0,0 +1,507 @@ +Except where otherwise noted, this content is Copyright (c) 2020, +`RTE `__ and licensed under a `CC-BY-4.0 +license `__. + +Begin Stochastic +================ + +What is a stochastic study ? +---------------------------- + +When you want to simulate a network adequacy, you can perform a +deterministic computation. That means you believe you won’t have too +much fluky behavior in the future. If you perform adequacy for the next +hour or day, it’s a good hypothesis. But if you simulate network for the +next week, month or year, it’s sound curious. + +Are you sur wind will blow next week or sun will shines ? If not, you +eolian or solar production could change. Can you warrant that no failure +will occur on your network next month or next year ? + +Of course, we can not predict future with such precision. It’s why we +use stochastic computation. Stochastic means there are fluky behavior in +the physics we want simulate. An single simulation is quiet useless, if +result can change due to little variation. + +The best solution could be to compute a *God function* which tell you +for each input variation (solar production, line, consumptions) what is +the adequacy result. Like that, Hadar has just to analyze function, its +derivatives, min, max, etc to predict future. But this God function +doesn’t exist, we just have an algorithm which tell us adequacy +according to one fixed set of input data. + +It’s why we use Monte Carlo algorithm. Monte Carlo run many scenarios to +analyze many different behavior. Scenario with more consumption in +cities, less solar production, less coal production or one line deleted +due to crash. By this method we recreate God function by sampling it +with the Monte-Carlo method. + + + +Describe example +---------------- + +We will reuse network seen in *Network Investment*. If you don’t read +this part, don’t worry we just reuse network no more. It’s look like + + + +We use data generated in the next topic +`Workflow `__. +Input data representes 10 scenarios with different load and eolien +productions. There are also random faults for nuclear and gas. These 10 +scenarios are unique. They are 10 random sampling on the *God function* +to try to predict more widely network adequacy + +.. code:: ipython3 + + import hadar as hd + import numpy as np + +.. code:: ipython3 + + def read_csv(name): + return np.genfromtxt('%s.csv' % name, delimiter=' ').T + +.. code:: ipython3 + + line = 2000 + study = hd.Study(horizon=168, nb_scn=10)\ + .network()\ + .node('a')\ + .consumption(name='load', cost=10**6, quantity=read_csv('load_A'))\ + .production(name='gas', cost=80, quantity=read_csv('gas'))\ + .node('b').consumption(name='load', cost=10**6, quantity=read_csv('load_B'))\ + .node('c').production(name='nuclear', cost=50, quantity=read_csv('nuclear'))\ + .node('d')\ + .consumption(name='load', cost=10**6, quantity=read_csv('load_D'))\ + .production(name='eolien', cost=20, quantity=read_csv('eolien'))\ + .link(src='a', dest='b', cost=5, quantity=line)\ + .link(src='b', dest='c', cost=5, quantity=line)\ + .link(src='c', dest='a', cost=5, quantity=line)\ + .link(src='c', dest='b', cost=10, quantity=line)\ + .link(src='c', dest='d', cost=10, quantity=line)\ + .link(src='d', dest='c', cost=10, quantity=line)\ + .build() + +.. code:: ipython3 + + optimizer = hd.LPOptimizer() + res = optimizer.solve(study) + +.. code:: ipython3 + + agg = hd.ResultAnalyzer(study, res) + plot = hd.HTMLPlotting(agg=agg, unit_symbol='MW', time_start='2020-06-19', time_end='2020-06-27', + node_coord={'a': [1.6264, 47.8842], 'b': [1.9061, 47.9118], 'c': [1.6175, 47.7097], 'd': [1.9314, 47.7090]}) + +Let’s start by a quick overview of adequacy by plotting a remain +available capacity. Blue squares mean network as enough energy to +sustain consumption. Red square mean network has a lack of adequacy. + +.. code:: ipython3 + + plot.network().rac_matrix() + + + +.. raw:: html + + + + + + +.. raw:: html + +
+ + +
+ +
+ + +As you see it, stochastic is important. Some scenario like 5th is +completly success. But if there are more consumption and less production +due to unpredictable event, you will have unadequacy. + +.. code:: ipython3 + + plot.network().node('b').consumption('load').timeline() + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + plot.network().node(node='b').stack(scn=7) + + + +.. raw:: html + +
+ + +
+ +
+ + +Hadar can also display valuable information about production. For +examples, gas plan seems turn off most of the time + +.. code:: ipython3 + + plot.network().node('a').production('gas').monotone(scn=7) + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + plot.network().node('d').production('eolien').timeline() + + + +.. raw:: html + +
+ + +
+ +
+ + +Then we can plot map to see exchange inside network + +.. code:: ipython3 + + plot.network().map(t=4, scn=7, zoom=1.6) + + + +.. raw:: html + +
+ + +
+ +
+ + diff --git a/docs/source/examples/Cost and Prioritization/Cost and Prioritization.rst b/docs/source/examples/Cost and Prioritization/Cost and Prioritization.rst new file mode 100644 index 0000000..e55fba9 --- /dev/null +++ b/docs/source/examples/Cost and Prioritization/Cost and Prioritization.rst @@ -0,0 +1,426 @@ +Except where otherwise noted, this content is Copyright (c) 2020, +`RTE `__ and licensed under a `CC-BY-4.0 +license `__. + +Cost and Prioritization +======================= + +Welcome to the next tutorial ! + +We will discover why hadar use cost and how to use it. + +Hadar is an adequacy optimizer, like every optimizer it needs cost to +determinie the best solution. In hadar, the cost to optimize represent a +kind of cost needed to perform network adequacy. Than means Hadar will +always try to: - use the cheaper production - use the cheaper path +inside network - if hadar can’t match consumption asked, it will turn +off cheaper unavailable consumption cost + +Production Prioritize +~~~~~~~~~~~~~~~~~~~~~ + +Let’s start an example with a single node, there are 3 types of +productions: solar, nuclear, oil. We want to use first all solar, then +switch to nuclear and use oil only as last chance. To see production +prioritize, we attach a growing consumption to this node. + +.. code:: ipython3 + + import numpy as np + import hadar as hd + +build study +^^^^^^^^^^^ + +.. code:: ipython3 + + study = hd.Study(horizon=30)\ + .network()\ + .node('a')\ + .consumption(name='load', cost=10**6, quantity=np.arange(30))\ + .production(name='solar', cost=10, quantity=10)\ + .production(name='nuclear', cost=100, quantity=10)\ + .production(name='oil', cost=1000, quantity=10)\ + .build() + + # tips: If you give just one element, hadar will extended it according horizon size and scenario size + +solve study +^^^^^^^^^^^ + +.. code:: ipython3 + + optimizer = hd.LPOptimizer() + res = optimizer.solve(study) + +instance an aggragator to analyze result +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: ipython3 + + agg = hd.ResultAnalyzer(study=study, result=res) + +inject aggregator inside plottting to visualize result +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: ipython3 + + plot = hd.HTMLPlotting(agg=agg) + +.. code:: ipython3 + + plot.network().node('a').stack() + + + +.. raw:: html + + + + + + +.. raw:: html + +
+ + +
+ +
+ + +Consumption Prioritize +~~~~~~~~~~~~~~~~~~~~~~ + +Consumption is bit different. Consumption cost is a unavailabilty cost. +Therefore, unlike production, Hadar must to use the highest consumption +cost first. + +For this example, imagine your are the futur. Hydrogen is the only +energy source. You have the classic load, you need to match absolutely. +Then you have car recharging consumption, has to be matched but could be +stopped time to time. And you have also bitcoin mining, which could be +stopped as you want. + +.. code:: ipython3 + + study = hd.Study(horizon=30)\ + .network()\ + .node('a')\ + .consumption(name='load', cost=10**6, quantity=10)\ + .consumption(name='car', cost=10**4, quantity=10)\ + .consumption(name='bitcoin', cost=10**3, quantity=10)\ + .production(name='hydrogen', cost=10, quantity=np.arange(30))\ + .build() + +.. code:: ipython3 + + res = optimizer.solve(study) + agg = hd.ResultAnalyzer(study=study, result=res) + plot = hd.HTMLPlotting(agg=agg) + +.. code:: ipython3 + + plot.network().node(node='a').stack(cons_kind='given') + + + +.. raw:: html + +
+ + +
+ +
+ + +Border Prioritize +~~~~~~~~~~~~~~~~~ + +As for production, border cost is a cost of use. Hadar will always +select the cheapest cost at first. + +For example, Belgium produces many eolien power. It’s a good new because +England and France has a peek of consumption. However send energy to +England by submarin cable is more expansive than send it to France by +traditional line. When we modelize network, we keep this technical cost +gap. Like that Hadar will firstly send energy to France and if some +energy remain, it will be send to England. + +.. code:: ipython3 + + study = hd.Study(horizon=2)\ + .network()\ + .node('be').production(name='eolien', cost=100, quantity=[10, 20])\ + .node('fr').consumption(name='load', cost=10**6, quantity=10)\ + .node('uk').consumption(name='load', cost=10**6, quantity=10)\ + .link(src='be', dest='fr', cost=10, quantity=10)\ + .link(src='be', dest='uk', cost=50, quantity=10)\ + .build() + +.. code:: ipython3 + + res = optimizer.solve(study) + agg = hd.ResultAnalyzer(study=study, result=res) + plot = hd.HTMLPlotting(agg=agg, + node_coord={'fr': [2.33, 48.86], 'be': [4.38, 50.83], 'uk': [0, 52]}) + +.. code:: ipython3 + + plot.network().map(t=0, zoom=2.7) + + + +.. raw:: html + +
+ + +
+ +
+ + +At t=0, Belgium has not enough energy for both. Hadar will send it to +France to optimize transfert cost. + +.. code:: ipython3 + + plot.network().map(t=1, zoom=2.7) + + + +.. raw:: html + +
+ + +
+ +
+ + +At t=1, Belgium has enough energy for both. + diff --git a/docs/source/examples/FR-DE Adequacy/FR-DE Adequacy.rst b/docs/source/examples/FR-DE Adequacy/FR-DE Adequacy.rst new file mode 100644 index 0000000..fb9a167 --- /dev/null +++ b/docs/source/examples/FR-DE Adequacy/FR-DE Adequacy.rst @@ -0,0 +1,431 @@ +Except where otherwise noted, this content is Copyright (c) 2020, +`RTE `__ and licensed under a `CC-BY-4.0 +license `__. + +FR-DE Adequacy +============== + +In this example, we will test hadar on a realistic (yet simplify) use +case. We will perform adequacy between France and Germainy during one +day. + +.. code:: ipython3 + + import pandas as pd + import numpy as np + import hadar as hd + +Import simplify dataset +^^^^^^^^^^^^^^^^^^^^^^^ + +.. code:: ipython3 + + fr = pd.read_csv('fr.csv') + de = pd.read_csv('de.csv') + +Build study +^^^^^^^^^^^ + +.. code:: ipython3 + + study = hd.Study(horizon=48).network() + +France loves nuclear, so in this example most of production are nuclear. +France has also a bit of solar and when needed country can turn on/off +coal generator. We want to optimize adequacy by reduce CO2 production. +Therefore: - solar is the cheaper at 10 - then we use nuclear at 30 - +and coal at 100 + +.. code:: ipython3 + + study = study.node('fr')\ + .consumption(name='load', cost=10**6, quantity=fr['cons'])\ + .production(name='solar', cost=10, quantity=fr['solar'])\ + .production(name='nuclear', cost=30, quantity=fr['nuclear'])\ + .production(name='coal', cost=100, quantity=fr['coal']) + +Germainy has stopped nuclear to switch from renewable energy. So we +increase solar and eolien production. When renewable energy are off, +Germainy need to start coal generation to match its consumption. Like +for France, we want to minimize CO2 production: - solar at 10 - eolien +at 15 - coal at 100 + +.. code:: ipython3 + + study = study.node('de')\ + .consumption(name='load', cost=10**6, quantity=de['cons'])\ + .production(name='solar', cost=10, quantity=de['solar'])\ + .production(name='eolien', cost=15, quantity=de['eolien'])\ + .production(name='coal', cost=100, quantity=de['coal']) + +Then both side links are set with same cost at 5. In this network, +Germany will be import from nuclear french before to start coal. And +France will use germain coal to avoid any loss of load. + +.. code:: ipython3 + + study = study\ + .link(src='fr', dest='de', cost=5, quantity=4000)\ + .link(src='de', dest='fr', cost=5, quantity=4000)\ + .build() + +.. code:: ipython3 + + optimizer = hd.LPOptimizer() + res = optimizer.solve(study) + +.. code:: ipython3 + + agg = hd.ResultAnalyzer(study, res) + plot = hd.HTMLPlotting(agg=agg, + unit_symbol='MW', # Set unit quantity + time_start='2020-02-01', # Set time interval + time_end='2020-02-02') + +.. code:: ipython3 + + plot.network().rac_matrix() + + + +.. raw:: html + + + + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + plot.network().node(node='fr').stack(prod_kind='used', cons_kind='asked') + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + plot.network().node('fr').consumption('load').gaussian(scn=0) + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + plot.network().node(node='de').stack() + + + +.. raw:: html + +
+ + +
+ +
+ + +Hadar found a loss of load near 6h in Germany and import from France. +Then France had a loss of load, and Hadar exports to France. + +.. code:: ipython3 + + plot.network().node('de').consumption(name='load').gaussian(scn=0) + + + +.. raw:: html + +
+ + +
+ +
+ + diff --git a/docs/source/examples/Get Started/Get Started.rst b/docs/source/examples/Get Started/Get Started.rst new file mode 100644 index 0000000..155efc4 --- /dev/null +++ b/docs/source/examples/Get Started/Get Started.rst @@ -0,0 +1,363 @@ +Get Started +=========== + +Except where otherwise noted, this content is Copyright (c) 2020, +`RTE `__ and licensed under a `CC-BY-4.0 +license `__. + +*Hadar is a adequacy python library for deterministic and stochastic +computation* + +Adequacy problem +~~~~~~~~~~~~~~~~ + +Each kind of network has a needs of adequacy. On one side, some network +nodes need to consume items such as watt, litter, package. And other +side, some network nodes produce items. Applying adequacy on network, is +tring to find the best available exchanges to avoid any lack at the best +cost. + +For example, a electric grid can have some nodes wich produce too more +power and some nodes wich produce not enough power. + +|image1| + +.. |image1| image:: figure.png + +In this case, at t=0, A produce 10 more and B need 10 more. Then nodes +are well balanced. And at t=2, B produce 10 more and A need 10 more. + +For this example, perform adequacy will done ten quantities exachanges +from A to B, then zero and at the end 10 quantities from B to A. + +Hadar compute adequacy from simple to complex network. For example, to +compute above network, just few line need:: + +Firstly, install hadar : **``pip install hadar``** + +.. code:: ipython3 + + import hadar as hd + +.. code:: ipython3 + + study = hd.Study(horizon=3)\ + .network()\ + .node('a')\ + .consumption(cost=10 ** 6, quantity=[20, 20, 20], name='load')\ + .production(cost=10, quantity=[30, 20, 10], name='prod')\ + .node('b')\ + .consumption(cost=10 ** 6, quantity=[20, 20, 20], name='load')\ + .production(cost=10, quantity=[10, 20, 30], name='prod')\ + .link(src='a', dest='b', quantity=[10, 10, 10], cost=2)\ + .link(src='b', dest='a', quantity=[10, 10, 10], cost=2)\ + .build() + +.. code:: ipython3 + + optimizer = hd.LPOptimizer() + res = optimizer.solve(study) + +Then you can analyze by yourself result or use hadar aggragator and +plotting + +.. code:: ipython3 + + plot = hd.HTMLPlotting(agg=hd.ResultAnalyzer(study, res), + node_coord={'a': [2.33, 48.86], 'b': [4.38, 50.83]}) + +.. code:: ipython3 + + plot.network().node('a').stack() + + + +.. raw:: html + + + + + + +.. raw:: html + +
+ + +
+ +
+ + +At starts **A** export it production. Then it needs to import. + +.. code:: ipython3 + + plot.network().node('b').stack(scn=0) + + + +.. raw:: html + +
+ + +
+ +
+ + +At start **B** needs to import then it can export its productions + +.. code:: ipython3 + + plot.network().map(t=0, zoom=2.5) + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + plot.network().map(t=2, zoom=2.5) + + + +.. raw:: html + +
+ + +
+ +
+ + diff --git a/docs/source/examples/Multi-Energies/Multi-Energies.rst b/docs/source/examples/Multi-Energies/Multi-Energies.rst new file mode 100644 index 0000000..c4ea1a9 --- /dev/null +++ b/docs/source/examples/Multi-Energies/Multi-Energies.rst @@ -0,0 +1,262 @@ +Multi-Energies +============== + +Hadar is designed to manage many kind of energy. Indeed, the only +restriction is the mathematical equation applies on each node, if user +case fill in this equation, Hadar can handle user case. + +That means Hadar is not designed for a specific energy. And moreover, +Hadar can handle many energies in one study, called *multi-energies*. To +do that, Hadar use *network* which organize node inside the same energy. +Node inside the same network manage the same energy, we use ``Link`` to +plug them together. If user has many network, therefore many energies, +he has to use ``Converter``. ``Converter`` is more powerfull than +``Link``, user can specify conversion from many different nodes to one +node. + +Engine example +~~~~~~~~~~~~~~ + +#### Set problem data + +No electricity in this tutorial, we will modelize an explosion engine. +There are three kind of energies in an engine: oil (*gramme*), +compressed air (*gramme*) and work (*Joule*). + +.. figure:: figure.png + :alt: figure + + figure + +Data problem: - `1g of oil = +41868J `__ +- for engine, ratio oil/air is +`15:1 `__, +1g of oil for 15g of air - engine has an efficiency about +`36% `__ + +Find Hadar ratios +^^^^^^^^^^^^^^^^^ + +In Hadar, we have to set ratio :math:`R_i` such as +:math:`In_i * R_i = Out` for each input :math:`i`. + +Equation applies to oil conversion gives: + +.. math:: + + + \begin{array}{rrcl} + &In_{oil} * R_{oil} &=& Work \\ + With\ 1g\ of\ oil,& 1 * R_{oil} &=& 41868 * 0,36 \\ + &R_{oil} &=& 15072.5 \\ + \end{array} + +Equation applies to air conversion gives: + +.. math:: + + + \begin{array}{rrcl} + &In_{air} * R_{air} &=& Work \\ + Replace\ with\ first\ equation,&In_{air} * R_{air} &=& In_{oil} * R_{oil} \\ + With\ 1g\ of\ oil,& 15 * R_{air} &=& 1 * R_{oil} \\ + &R_{air} &=& R_{oil} / 15 \\ + &R_{air} &=& 1005 + \end{array} + +.. code:: ipython3 + + import hadar as hd + import numpy as np + +Work is modellized by a consumption such as :math:`10000*(1-e^{-t/25})` + +.. code:: ipython3 + + work = 10000*(1 - np.exp(-np.arange(100)/25)) + +.. code:: ipython3 + + study = hd.Study(horizon=100)\ + .network('work')\ + .node('work')\ + .consumption(name='work', cost=10**6, quantity=work)\ + .network('oil')\ + .node('oil')\ + .production(name='oil', cost=10, quantity=10)\ + .to_converter(name='engine', ratio=15072.5)\ + .network('air')\ + .node('air')\ + .production(name='air', cost=10, quantity=150)\ + .to_converter(name='engine', ratio=1005)\ + .converter(name='engine', to_network='work', to_node='work', max=10000)\ + .build() + +.. code:: ipython3 + + optim = hd.LPOptimizer() + res = optim.solve(study) + +.. code:: ipython3 + + agg = hd.ResultAnalyzer(study=study, result=res) + plot = hd.HTMLPlotting(agg=agg, unit_symbol='J') + +.. code:: ipython3 + + plot.network('work').node('work').stack() + + + +.. raw:: html + + + + + + +.. raw:: html + +
+ + +
+ +
+ + +Work energy comes from engine converter. If we analyze oil and air used +in result, we found correct ratio. + +.. code:: ipython3 + + oil = agg.network('oil').scn(0).node('oil').production('oil').time()['used'] + air = agg.network('air').scn(0).node('air').production('air').time()['used'] + +.. code:: ipython3 + + (air / oil).plot() + + + + +.. parsed-literal:: + + + + + + +.. image:: output_16_1.png + + diff --git a/docs/source/examples/Network Investment/Network Investment.rst b/docs/source/examples/Network Investment/Network Investment.rst new file mode 100644 index 0000000..7e410b0 --- /dev/null +++ b/docs/source/examples/Network Investment/Network Investment.rst @@ -0,0 +1,647 @@ +Except where otherwise noted, this content is Copyright (c) 2020, +`RTE `__ and licensed under a `CC-BY-4.0 +license `__. + +Network Investment +================== + +Welcome to this new tutorial. Off course Hadar is well designed to +compute study for network adequacy. You can launch Hadar to compute +adequacy for the next second or next year. + +But Hadar can also be used like a asset investment tool. In this +example, thanks to Hadar, we will make the best choice for renewable +energy and network investment. + + + +We have a small region, with metropole which doesn’t produce anything, a +nuclear plan and two small cities with production. + +First step parse data with pandas (and plot them) + +.. code:: ipython3 + + import numpy as np + import pandas as pd + import hadar as hd + import plotly.graph_objects as go + +Input data +~~~~~~~~~~ + +.. code:: ipython3 + + a = pd.read_csv('a.csv', index_col='date') + fig = go.Figure() + fig.add_traces(go.Scatter(x=a.index, y=a['consumption'], name='load')) + fig.add_traces(go.Scatter(x=a.index, y=a['gas'], name='gas')) + fig.update_layout(title_text='Node A', yaxis_title='MW') + + + +.. raw:: html + + + + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + b = pd.read_csv('b.csv', index_col='date') + fig = go.Figure() + fig.add_traces(go.Scatter(x=b.index, y=b['consumption'], name='load')) + fig.update_layout(title_text='Node B (only consumption)', yaxis_title='MW') + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + c = pd.read_csv('c.csv', index_col='date') + fig = go.Figure() + fig.add_traces(go.Scatter(x=c.index, y=c['nuclear'], name='load')) + fig.update_layout(title_text='Node C (only production)', yaxis_title='MW') + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + d = pd.read_csv('d.csv', index_col='date') + fig = go.Figure() + fig.add_traces(go.Scatter(x=d.index, y=d['consumption'], name='load')) + fig.add_traces(go.Scatter(x=d.index, y=d['eolien'], name='eolien')) + fig.update_layout(title_text='Node D', yaxis_title='MW') + + + +.. raw:: html + +
+ + +
+ +
+ + +Base Study +---------- + +Next step, code this network with Hadar + +.. code:: ipython3 + + line = np.ones(8760) * 2000 # 2000 MW + +.. code:: ipython3 + + base = hd.Study(horizon=8760)\ + .network()\ + .node('a')\ + .consumption(name='load', cost=10**6, quantity=a['consumption'])\ + .production(name='gas', cost=80, quantity=a['gas'])\ + .node('b')\ + .consumption(name='load', cost=10**6, quantity=b['consumption'])\ + .node('c')\ + .production(name='nuclear', cost=50, quantity=c['nuclear'])\ + .node('d')\ + .consumption(name='load', cost=10**6, quantity=d['consumption'])\ + .production(name='eolien', cost=20, quantity=d['eolien'])\ + .link(src='a', dest='b', cost=5, quantity=line)\ + .link(src='b', dest='c', cost=5, quantity=line)\ + .link(src='c', dest='a', cost=5, quantity=line)\ + .link(src='c', dest='b', cost=10, quantity=line)\ + .link(src='c', dest='d', cost=10, quantity=line)\ + .link(src='d', dest='c', cost=10, quantity=line)\ + .build() + +.. code:: ipython3 + + optimizer = hd.LPOptimizer() + +.. code:: ipython3 + + def compute_cost(study): + res = optimizer.solve(study) + agg = hd.ResultAnalyzer(study=study, result=res) + return agg.get_cost().sum(axis=1), res.benchmark + +.. code:: ipython3 + + def print_bench(bench): + print('mapper', bench.mapper) + print('modeler', sum(bench.modeler)) + print('solver', sum(bench.solver)) + print('total', bench.total) + +.. code:: ipython3 + + base_cost, bench = compute_cost(base) + base_cost = base_cost[0] + +Find best place for solar +~~~~~~~~~~~~~~~~~~~~~~~~~ + +An investissor want to build a solar park with solar cells. According to +last last data meteo, he could except the amount of production from this +park. (Solar radiation is the same on each node of network). + +**What is the best node to install these solar pans ?** (B is excluded +because there are not enough space) + +.. code:: ipython3 + + park = pd.read_csv('solar.csv', index_col='date') + fig = go.Figure() + fig.add_traces(go.Scatter(x=park.index, y=park['solar'], name='solar')) + fig.update_layout(title_text='Forecast Solar Park Power', yaxis_title='MW') + + + +.. raw:: html + +
+ + +
+ +
+ + +We can build one study for each different scenarios. However, Hadar can +compute many scenarios at once for a more efficient compute. Result are +the same. The possibility to compute many scenarios at once, is very +important for next topic `Stochastic +Study `__. + +.. code:: ipython3 + + def build_sparce_data(total: int, at, data) -> np.ndarray: + """ + Build many scenarios input where all scenario is empty but one. + :param total: number of scenario to generate + :param at: scenario index to fill + :param data: data to fill in selected scenario + + :return: matrix with shape (nb_scn, horizon) where only one scenario is not zero. + """ + if isinstance(data, pd.DataFrame): + data = data.values.flatten() + sparce = np.ones((total, data.size)) + sparce[at, :] = data + return sparce + +We use start three studies one for each node. + +.. code:: ipython3 + + solar = hd.Study(horizon=8760, nb_scn=3)\ + .network()\ + .node('a')\ + .consumption(name='load', cost=10**6, quantity=a['consumption'])\ + .production(name='gas', cost=80, quantity=a['gas'])\ + .production(name='solar', cost=10, quantity=build_sparce_data(total=3, at=0, data=park))\ + .node('b')\ + .consumption(name='load', cost=10**6, quantity=b['consumption'])\ + .node('c')\ + .production(name='nuclear', cost=50, quantity=c['nuclear'])\ + .production(name='solar', cost=10, quantity=build_sparce_data(total=3, at=1, data=park))\ + .node('d')\ + .consumption(name='load', cost=10**6, quantity=d['consumption'])\ + .production(name='eolien', cost=20, quantity=d['eolien'])\ + .production(name='solar', cost=10, quantity=build_sparce_data(total=3, at=2, data=park))\ + .link(src='a', dest='b', cost=5, quantity=line)\ + .link(src='b', dest='c', cost=5, quantity=line)\ + .link(src='c', dest='a', cost=5, quantity=line)\ + .link(src='c', dest='b', cost=10, quantity=line)\ + .link(src='c', dest='d', cost=10, quantity=line)\ + .link(src='d', dest='c', cost=10, quantity=line)\ + .build() + +.. code:: ipython3 + + costs, bench = compute_cost(solar) + costs = pd.Series(data=costs, name='cost', index=['a', 'c', 'd']) + + + +.. code:: ipython3 + + (base_cost - costs) / base_cost * 100 + + + + +.. parsed-literal:: + + a 8.070145 + c 2.342062 + d 2.736793 + Name: cost, dtype: float64 + + + +As we can see, network is more efficient if solar park is installed one +**node A** (8% more efficient than only 2-3% for other node) + +Find best place with on more line +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Add an extra difficulties ! Region want to invest in a new line between +A->C, D->B, A->D, D->A. + +In this case, **What is the best place to install solar park and what is +the more usefull line to build ?** + +.. code:: ipython3 + + solar_line = hd.Study(horizon=8760, nb_scn=12)\ + .network()\ + .node('a')\ + .consumption(name='load', cost=10**6, quantity=a['consumption'])\ + .production(name='gas', cost=80, quantity=a['gas'])\ + .production(name='solar', cost=10, quantity=build_sparce_data(total=12, at=[0, 3, 6, 9], data=park))\ + .node('b')\ + .consumption(name='load', cost=10**6, quantity=b['consumption'])\ + .node('c')\ + .production(name='nuclear', cost=50, quantity=c['nuclear'])\ + .production(name='solar', cost=10, quantity=build_sparce_data(total=12, at=[1, 4, 7, 10], data=park))\ + .node('d')\ + .consumption(name='load', cost=10**6, quantity=d['consumption'])\ + .production(name='eolien', cost=20, quantity=d['eolien'])\ + .production(name='solar', cost=10, quantity=build_sparce_data(total=12, at=[2, 5, 8, 11], data=park))\ + .link(src='a', dest='b', cost=5, quantity=line)\ + .link(src='b', dest='c', cost=5, quantity=line)\ + .link(src='c', dest='a', cost=5, quantity=line)\ + .link(src='c', dest='b', cost=10, quantity=line)\ + .link(src='c', dest='d', cost=10, quantity=line)\ + .link(src='d', dest='c', cost=10, quantity=line)\ + .link(src='a', dest='c', cost=10, quantity=build_sparce_data(total=12, at=[0, 1, 2], data=line))\ + .link(src='d', dest='b', cost=10, quantity=build_sparce_data(total=12, at=[3, 4, 5], data=line))\ + .link(src='a', dest='d', cost=10, quantity=build_sparce_data(total=12, at=[6, 7, 8], data=line))\ + .link(src='d', dest='a', cost=10, quantity=build_sparce_data(total=12, at=[9, 10, 11], data=line))\ + .build() + +.. code:: ipython3 + + costs2, bench = compute_cost(solar_line) + costs2 = pd.DataFrame(data=costs2.reshape(4, 3), + index=['a->c', 'd->b', 'a->d', 'd->a'], columns=['a', 'c', 'd']) + + + +.. code:: ipython3 + + (base_cost - costs2) / base_cost * 100 + + + + +.. raw:: html + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
acd
a->c8.4450712.7422563.128567
d->b47.00579245.56943947.355664
a->d9.3072693.5270783.642307
d->a46.99431545.55780647.343787
+
+ + + +Very interesting, new line is a game changer. D->A and D->B seem most +valuable lines. If D->B is created, it’s more efficient to install solar +park on **node D** ! + diff --git a/docs/source/examples/Storage/Storage.rst b/docs/source/examples/Storage/Storage.rst new file mode 100644 index 0000000..ee58ff9 --- /dev/null +++ b/docs/source/examples/Storage/Storage.rst @@ -0,0 +1,534 @@ +Storage +======= + +Except where otherwise noted, this content is Copyright (c) 2020, +`RTE `__ and licensed under a `CC-BY-4.0 +license `__. + +We has already seen Consumption, Production and Link to attach on node. +Hadar has also a Stockage element. We will work on a simple network with +two nodes : one with two producitons (stochastic and constant) other +with consumption and stockage + +.. figure:: figure.png + :alt: img + + img + +.. code:: ipython3 + + import numpy as np + import hadar as hd + +Create data +~~~~~~~~~~~ + +.. code:: ipython3 + + np.random.seed(12684681) + eolien = np.random.rand(168) * 500 + 200 # random from 200 to 700 + load = np.sin(np.linspace(-1, -1+np.pi*14, 168)) * 250 + 750 # sinus moving 500 to 1000 + +Adequacy without storage +~~~~~~~~~~~~~~~~~~~~~~~~ + +Start storage by remove storage ! + +.. code:: ipython3 + + study = hd.Study(horizon=eolien.size)\ + .network()\ + .node('a')\ + .production(name='gas', cost=100, quantity=200)\ + .production(name='nulcear', cost=50, quantity=300)\ + .production(name='eolien', cost=10, quantity=eolien)\ + .node('b')\ + .consumption(name='load', cost=10 ** 6, quantity=load)\ + .link(src='a', dest='b', cost=1, quantity=2000)\ + .build() + + optim = hd.LPOptimizer() + res = optim.solve(study) + + plot_without = hd.HTMLPlotting(agg=hd.ResultAnalyzer(study=study, result=res), unit_symbol='MW') + +.. code:: ipython3 + + plot_without.network().node('b').stack() + + + +.. raw:: html + + + + + + +.. raw:: html + +
+ + +
+ +
+ + +Node B has a lot of lost of load. Network has not enough power to +sustain consumption during peak. + +.. code:: ipython3 + + plot_without.network().node('a').stack() + + + +.. raw:: html + +
+ + +
+ +
+ + +Productions are used immediately just to match load + +Use storage +~~~~~~~~~~~ + +Now we add a storage. In our case cell efficiency is 80%, efficient must +be < 1, Hadar use ``eff=0.99`` as default. Other important parameter is +``cost`` it represents cost of storage per quantity during on time-step. +``cost`` at 0 or positive mean we want to minimize storage used. By +default Hadar use ``cost=0``. + +So in the configuration, ``cost=0``\ and ``eff=0.80``. Therefore, a +quantity stored *costs* 25% (:math:`\frac{1}{0.8} = 1.25`) higher than +same production without stored before. At any time Hadar has choice +between these productions and cost. + +======= ======== ======================= +Prod use cost  stored before use cost +======= ======== ======================= +eolien  10 12,5 +nuclear  50  62,75 +gas 100 125 +======= ======== ======================= + +Moreover than just fix lost of load, storage can also optimize +productions. Looks, a stored nuclear or eolien production is cheaper +than a direct gas production. Hadar knows it and will use it ! + +.. code:: ipython3 + + study = hd.Study(horizon=eolien.size)\ + .network()\ + .node('a')\ + .production(name='gas', cost=100, quantity=200)\ + .production(name='nulcear', cost=50, quantity=300)\ + .production(name='eolien', cost=10, quantity=eolien)\ + .node('b')\ + .consumption(name='load', cost=10 ** 6, quantity=load)\ + .storage(name='cell', init_capacity=200, capacity=800, flow_in=400, flow_out=400, eff=.8)\ + .link(src='a', dest='b', cost=1, quantity=2000)\ + .build() + + res = optim.solve(study) + plot = hd.HTMLPlotting(agg=hd.ResultAnalyzer(study=study, result=res), unit_symbol='MW') + +.. code:: ipython3 + + plot.network().node('b').stack() + + + +.. raw:: html + +
+ + +
+ +
+ + +Yeah ! We avoid network shutdown ! + +.. code:: ipython3 + + plot.network().node('b').storage('cell').candles() + + + +.. raw:: html + +
+ + +
+ +
+ + +Hadar fills cell before each peaks. + +.. code:: ipython3 + + plot.network().node('a').stack() + + + +.. raw:: html + +
+ + +
+ +
+ + +And yes, Hadars starts nuclear before peak, and use less gas during +peak. + +Use storage with negative cost +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +What happen, if we use negative cost ? + +In this case, storage has some interest. If interest is higher than gain +from optimizing productions. Hadar will automatically fill cell. + +.. code:: ipython3 + + study = hd.Study(horizon=eolien.size)\ + .network()\ + .node('a')\ + .production(name='gas', cost=100, quantity=200)\ + .production(name='nulcear', cost=50, quantity=300)\ + .production(name='eolien', cost=10, quantity=eolien)\ + .node('b')\ + .consumption(name='load', cost=10 ** 6, quantity=load)\ + .storage(name='cell', init_capacity=200, capacity=800, flow_in=400, flow_out=400, eff=.8, cost=-10)\ + .link(src='a', dest='b', cost=1, quantity=2000)\ + .build() + + res = optim.solve(study) + plot_cost_neg = hd.HTMLPlotting(agg=hd.ResultAnalyzer(study=study, result=res), unit_symbol='MW') + +.. code:: ipython3 + + plot_cost_neg.network().node('b').storage('cell').candles() + + + +.. raw:: html + +
+ + +
+ +
+ + +Hadar doesn’t try to optimize import, now it saves into storage to earn +interest. + diff --git a/docs/source/examples/Workflow Advanced/Workflow Advanced.rst b/docs/source/examples/Workflow Advanced/Workflow Advanced.rst new file mode 100644 index 0000000..1710f93 --- /dev/null +++ b/docs/source/examples/Workflow Advanced/Workflow Advanced.rst @@ -0,0 +1,678 @@ +Workflow Advanced +================= + +In `Workflow `__ +we saw how to easly create simple stage and links stages to build +pipeline. It’s time to see complet workflow features to create more +complex Stage. + +Data format +----------- + +First takes a look at how data are represented inside stage, *a, b, c* +are column names provide by user and used by stages: + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +:: + + + + + + + + + + + + + + +.. raw:: html + +
+ +.. raw:: html + + + +scn 0 + +.. raw:: html + + + +scn 1 + +.. raw:: html + + + +scn … + +.. raw:: html + +
+ +t + +.. raw:: html + + + +a + +.. raw:: html + + + +b + +.. raw:: html + + + +… + +.. raw:: html + + + +a + +.. raw:: html + + + +b + +.. raw:: html + + + +… + +.. raw:: html + + + +a + +.. raw:: html + + + +b + +.. raw:: html + + + +… + +.. raw:: html + +
+ +0 + +.. raw:: html + + + +\_ + +.. raw:: html + + + +\_ + +.. raw:: html + + + +\_ + +.. raw:: html + + + +\_ + +.. raw:: html + + + +\_ + +.. raw:: html + + + +\_ + +.. raw:: html + + + +\_ + +.. raw:: html + + + +\_ + +.. raw:: html + + + +\_ + +.. raw:: html + +
1_________
..._________
+ +Pipeline could be more flexible, and allow user input without scenarios. +Like that, it will be standardized by adding a default 0th scenario. + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +.. raw:: html + + + +:: + + + + + + + + + + +.. raw:: html + +
+ +t + +.. raw:: html + + + +a + +.. raw:: html + + + +b + +.. raw:: html + + + +… + +.. raw:: html + +
+ +0 + +.. raw:: html + + + +\_ + +.. raw:: html + + + +\_ + +.. raw:: html + + + +\_ + +.. raw:: html + +
1___
...___
+ +Constraint on input and output +------------------------------ + +As you see above, data contains scenarios and each scenario contains +columns with generic names. These names become a constraint. For example +some stages expectes to receive strict name, or will produce other +columns with new name. Hadar provide a mechanism to handle this +complexity called *Plug*. You has already seen ``hd.FreePlug`` which +mean stage has no constraint: It doesn’t expected any particular input +and doesn’t produce specific column. + +For example, if you juste need to multiply by twice data, you can create +a Stage with ``FreePlug``: + +.. code:: ipython3 + + import hadar as hd + import numpy as np + import pandas as pd + +.. code:: ipython3 + + class Twice(hd.Stage): + def __init__(self): + Stage.__init__(plug=hd.FreePlug()) + def _process_timeline(tl): + return tl * 2 + +It simple, but some time, you expected strictly column name to process +timeline. In this case you will use +``hd.RestrictedPlug(input, output)``, *input* declare what column names +you expected to perform calcul, *output* says what is new column names +created during calcul. + +Now we care about column name, we often need to apply calcul scenario by +scenario and not at the global dataframe. To handle, this mechanism, +hadar provides you a ``FocusStage`` which give you a +``_process_scenario(scn, tl)`` to implement. + +In last example, we created a Stage to generate wind power, just by +apply a linear random generation. Now we want more precise generation. +Whereas previous stage just use max variables to generate linear random, +we use two variables *mean* and *std* to generate normal random. + +.. code:: ipython3 + + class Wind(hd.FocusStage): # Compute will be done scenario by scenario so we use FocusStage + def __init__(self): + # Use Restricted plug to force constraint + hd.FocusStage.__init__(self, plug=hd.RestrictedPlug(inputs=['mean', 'std'], outputs=['wind'])) + + def _process_scenarios(self, nb_scn, tl): + return tl['mean'] + np.random.randn(tl.shape[0]) * tl['std'] + +Wind can be plug, upstream stages have to provide *mean* and *std*, +downstream stage should use *wind*. For example, ``hd.Clip`` and +``hd.RepeadScenario`` are a free plug, you can plug them every where + +.. code:: ipython3 + + pipe = hd.RepeatScenario(5) + Wind() + hd.Clip(lower=0) # Make sur no negative production are generated + +But if you want to plug ``Fault``, error will raise, because ``Fault`` +expectes a *quantity* column + +.. code:: ipython3 + + try: + pipe = hd.RepeatScenario(5) + Wind() + hd.Clip(lower=0) \ + + hd.Fault(occur_freq=0.01, loss=100, downtime_min=1, downtime_max=10) + except ValueError as e: + print('ValueError:', e) + + +.. parsed-literal:: + + ValueError: Pipeline can't be added current outputs are ['wind'] and Fault has input ['quantity'] + + +In this case, you can use ``hd.Rename`` to refix stages with good column +name. To summerize pipeline : 1. copy 5 time data in new scenarios 2. +apply random generation for each scenarios 3. cap data below 0 (a +negativ productoin doesn’t exist) 4. Rename data column from *wind* to +*quantity* 5. Generate random fault for each scenarios + +.. code:: ipython3 + + pipe = hd.RepeatScenario(5) + Wind() + hd.Clip(lower=0) \ + + hd.Rename(wind='quantity') + hd.Fault(occur_freq=0.01, loss=100, downtime_min=1, downtime_max=10) + +Check is performed when stages are linked together, but also when user +give input data. Lines above will raise error since input doesn’t have +*mean* columns name + +.. code:: ipython3 + + t = np.linspace(0, 4*3.14, 168) + +.. code:: ipython3 + + try: + i = pd.DataFrame({'NOT-mean': np.sin(t) * 1000 + 1000, 'std': np.sin(t*2)* 200 + 200}) + o = pipe(i) + except ValueError as e: + print('ValueError:', e) + + +.. parsed-literal:: + + ValueError: Pipeline accept ['mean', 'std'] in input, but receive ['NOT-mean' 'std'] + + +.. code:: ipython3 + + i = pd.DataFrame({'mean': np.sin(t) * 1000 + 1000, 'std': np.sin(t*2) * 200 + 200}) + o = pipe(i.copy()) + +.. code:: ipython3 + + import plotly.graph_objects as go + +.. code:: ipython3 + + fig = go.Figure() + + fig.add_traces(go.Scatter(x=t, y=i['mean'], name='mean')) + fig.add_traces(go.Scatter(x=t, y=i['std']+i['mean'], name='std+', line=dict(color='red', dash='dash'))) + fig.add_traces(go.Scatter(x=t, y=-i['std']+i['mean'], name='std-', line=dict(color='red', dash='dash'))) + for n in range(5): + fig.add_traces(go.Scatter(x=t, y=o[n]['quantity'], name='wind %d' % n, line=dict(color='rgba(100, 100, 100, 0.5)'))) + + fig + + + +.. raw:: html + + + + + + +.. raw:: html + +
+ + +
+ +
+ + diff --git a/docs/source/examples/Workflow/Workflow.rst b/docs/source/examples/Workflow/Workflow.rst new file mode 100644 index 0000000..bee9b93 --- /dev/null +++ b/docs/source/examples/Workflow/Workflow.rst @@ -0,0 +1,423 @@ +Except where otherwise noted, this content is Copyright (c) 2020, +`RTE `__ and licensed under a `CC-BY-4.0 +license `__. + +Workflow +======== + +What is Worflow ? +----------------- + +When you want to simulate adequacy in a network for the next weeks or +month, you need to create stochastic study, and generate scenarios (c.f. +`Begin +Stochastic `__) + +Workflow is the preprocessing module for Hadar. Workflow will help user +to generate scenarios and sample them to create a stochastic study. It’s +a toolbox to create pipelines to transform data for optimizer. + +With workflow, you will plug stage themself to create pipeline. Stages +can already be developed or you can develop your own Stage. + +Recreate data used in *Begin Stochastic* +---------------------------------------- + +To understand workflow power we will generate data previously used in +`Begin +Stochastic `__ + +Build fault pipelines +~~~~~~~~~~~~~~~~~~~~~ + +Let’s begin by constant production like nuclear and gas. These +productions are not stochastic by default. However fault can occur and +it’s what we will generate. For this example all stages belongs to hadar +ready-to-use library. + +.. code:: ipython3 + + import hadar as hd + import numpy as np + import pandas as pd + import plotly.graph_objects as go + +.. code:: ipython3 + + # We generate 5 fault scenarios where a fault remove 100 MW with an odd of 1% by timestep, + # minimum downtime are one step (one hour) and maximum downtime are 12 step. + fault_pipe = hd.RepeatScenario(n=5) + hd.Fault(loss=300, occur_freq=0.01, downtime_min=1, downtime_max=12) + hd.ToShuffler('quantity') + +Build stochastic pipelines +-------------------------- + +In this case, we have to develop our own stage. Let’s begin with wind. +We know max wind power, we will apply a linear random between 0 to max +for each timestep + +.. code:: ipython3 + + class WindRandom(hd.Stage): + def __init__(self): + hd.Stage.__init__(self, plug=hd.FreePlug()) # We will see in other example what is FreePlug + + # Method to implement from Stage to create your own Stage with its behaviour + def _process_timeline(self, timeline: pd.DataFrame) -> pd.DataFrame: + return timeline * np.random.rand(*timeline.shape) + +.. code:: ipython3 + + wind_pipe = hd.RepeatScenario(n=3) + WindRandom() + hd.ToShuffler('quantity') + +Then we generate load. For load we will apply a cumulative normal +distribution with given value as mean. + +.. code:: ipython3 + + class LoadRandom(hd.Stage): + def __init__(self): + hd.Stage.__init__(self, plug=hd.FreePlug()) # We will see in other example what is FreePlug + + # Method to implement from Stage to create your own Stage with its behaviour + def _process_timeline(self, timeline: pd.DataFrame) -> pd.DataFrame: + return timeline + np.cumsum(np.random.randn(*timeline.shape) * 10, axis=0) + +.. code:: ipython3 + + load_pipe = hd.RepeatScenario(n=3) + LoadRandom() + hd.ToShuffler('quantity') + +Generate and sample +------------------- + +We use Shuffler object to generate data by pipeline and then sample 10 +scenarios + + + +.. code:: ipython3 + + ones = pd.DataFrame({'quantity': np.ones(168)}) + # Load are simply a sinus shape + sinus = pd.DataFrame({'quantity': np.sin(np.linspace(-1, -1+np.pi*14, 168))*.2 + .8}) + + shuffler = hd.Shuffler() + shuffler.add_pipeline(name='gas', data=ones * 1000, pipeline=fault_pipe) + shuffler.add_pipeline(name='nuclear', data=ones * 5000, pipeline=fault_pipe) + shuffler.add_pipeline(name='eolien', data=ones * 1000, pipeline=wind_pipe) + shuffler.add_pipeline(name='load_A', data=sinus * 2000, pipeline=load_pipe) + shuffler.add_pipeline(name='load_B', data=sinus * 3000, pipeline=load_pipe) + shuffler.add_pipeline(name='load_D', data=sinus * 1000, pipeline=load_pipe) + +.. code:: ipython3 + + sampling = shuffler.shuffle(nb_scn=10) + +.. code:: ipython3 + + def input_plot(title, raw, generate): + x = np.arange(raw.size) + fig = go.Figure() + for i, scn in enumerate(generate): + fig.add_trace(go.Scatter(x=x, y=scn, name='scn %d' % i, line=dict(color='rgba(100, 100, 100, 0.2)'))) + + fig.add_traces(go.Scatter(x=x, y=raw.values.T[0], name='raw')) + + fig.update_layout(title_text=title) + return fig + +.. code:: ipython3 + + input_plot('Gas', ones * 1000, sampling['gas']) + + + +.. raw:: html + + + + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + input_plot('Nuclear', ones * 5000, sampling['nuclear']) + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + input_plot('eolien', ones * 1000, sampling['eolien']) + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + input_plot('load_A', sinus * 2000, sampling['load_A']) + + + +.. raw:: html + +
+ + +
+ +
+ + +.. code:: ipython3 + + # for name, values in sampling.items(): + # np.savetxt('../Begin Stochastic/%s.csv' % name, values.T, delimiter=' ', fmt='%04.2f') + diff --git a/examples b/examples deleted file mode 120000 index 9019710..0000000 --- a/examples +++ /dev/null @@ -1 +0,0 @@ -docs/source/examples \ No newline at end of file diff --git a/docs/source/examples/Analyze Result/Analyze Result.ipynb b/examples/Analyze Result/Analyze Result.ipynb similarity index 100% rename from docs/source/examples/Analyze Result/Analyze Result.ipynb rename to examples/Analyze Result/Analyze Result.ipynb diff --git a/docs/source/examples/Begin Stochastic/Begin Stochastic.ipynb b/examples/Begin Stochastic/Begin Stochastic.ipynb similarity index 100% rename from docs/source/examples/Begin Stochastic/Begin Stochastic.ipynb rename to examples/Begin Stochastic/Begin Stochastic.ipynb diff --git a/docs/source/examples/Begin Stochastic/eolien.csv b/examples/Begin Stochastic/eolien.csv similarity index 100% rename from docs/source/examples/Begin Stochastic/eolien.csv rename to examples/Begin Stochastic/eolien.csv diff --git a/docs/source/examples/Begin Stochastic/gas.csv b/examples/Begin Stochastic/gas.csv similarity index 100% rename from docs/source/examples/Begin Stochastic/gas.csv rename to examples/Begin Stochastic/gas.csv diff --git a/docs/source/examples/Begin Stochastic/load_A.csv b/examples/Begin Stochastic/load_A.csv similarity index 100% rename from docs/source/examples/Begin Stochastic/load_A.csv rename to examples/Begin Stochastic/load_A.csv diff --git a/docs/source/examples/Begin Stochastic/load_B.csv b/examples/Begin Stochastic/load_B.csv similarity index 100% rename from docs/source/examples/Begin Stochastic/load_B.csv rename to examples/Begin Stochastic/load_B.csv diff --git a/docs/source/examples/Begin Stochastic/load_D.csv b/examples/Begin Stochastic/load_D.csv similarity index 100% rename from docs/source/examples/Begin Stochastic/load_D.csv rename to examples/Begin Stochastic/load_D.csv diff --git a/docs/source/examples/Begin Stochastic/nuclear.csv b/examples/Begin Stochastic/nuclear.csv similarity index 100% rename from docs/source/examples/Begin Stochastic/nuclear.csv rename to examples/Begin Stochastic/nuclear.csv diff --git a/docs/source/examples/Cost and Prioritization/Cost and Prioritization.ipynb b/examples/Cost and Prioritization/Cost and Prioritization.ipynb similarity index 100% rename from docs/source/examples/Cost and Prioritization/Cost and Prioritization.ipynb rename to examples/Cost and Prioritization/Cost and Prioritization.ipynb diff --git a/docs/source/examples/FR-DE Adequacy/FR-DE Adequacy.ipynb b/examples/FR-DE Adequacy/FR-DE Adequacy.ipynb similarity index 100% rename from docs/source/examples/FR-DE Adequacy/FR-DE Adequacy.ipynb rename to examples/FR-DE Adequacy/FR-DE Adequacy.ipynb diff --git a/docs/source/examples/FR-DE Adequacy/de.csv b/examples/FR-DE Adequacy/de.csv similarity index 100% rename from docs/source/examples/FR-DE Adequacy/de.csv rename to examples/FR-DE Adequacy/de.csv diff --git a/docs/source/examples/FR-DE Adequacy/fr.csv b/examples/FR-DE Adequacy/fr.csv similarity index 100% rename from docs/source/examples/FR-DE Adequacy/fr.csv rename to examples/FR-DE Adequacy/fr.csv diff --git a/docs/source/examples/Get Started/Get Started.ipynb b/examples/Get Started/Get Started.ipynb similarity index 100% rename from docs/source/examples/Get Started/Get Started.ipynb rename to examples/Get Started/Get Started.ipynb diff --git a/docs/source/examples/Get Started/figure.drawio b/examples/Get Started/figure.drawio similarity index 100% rename from docs/source/examples/Get Started/figure.drawio rename to examples/Get Started/figure.drawio diff --git a/docs/source/examples/Multi-Energies/Multi-Energies.ipynb b/examples/Multi-Energies/Multi-Energies.ipynb similarity index 100% rename from docs/source/examples/Multi-Energies/Multi-Energies.ipynb rename to examples/Multi-Energies/Multi-Energies.ipynb diff --git a/docs/source/examples/Multi-Energies/figure.drawio b/examples/Multi-Energies/figure.drawio similarity index 100% rename from docs/source/examples/Multi-Energies/figure.drawio rename to examples/Multi-Energies/figure.drawio diff --git a/docs/source/examples/Network Investment/12scn.prof b/examples/Network Investment/12scn.prof similarity index 100% rename from docs/source/examples/Network Investment/12scn.prof rename to examples/Network Investment/12scn.prof diff --git a/docs/source/examples/Network Investment/Network Investment.ipynb b/examples/Network Investment/Network Investment.ipynb similarity index 100% rename from docs/source/examples/Network Investment/Network Investment.ipynb rename to examples/Network Investment/Network Investment.ipynb diff --git a/docs/source/examples/Network Investment/a.csv b/examples/Network Investment/a.csv similarity index 100% rename from docs/source/examples/Network Investment/a.csv rename to examples/Network Investment/a.csv diff --git a/docs/source/examples/Network Investment/b.csv b/examples/Network Investment/b.csv similarity index 100% rename from docs/source/examples/Network Investment/b.csv rename to examples/Network Investment/b.csv diff --git a/docs/source/examples/Network Investment/c.csv b/examples/Network Investment/c.csv similarity index 100% rename from docs/source/examples/Network Investment/c.csv rename to examples/Network Investment/c.csv diff --git a/docs/source/examples/Network Investment/d.csv b/examples/Network Investment/d.csv similarity index 100% rename from docs/source/examples/Network Investment/d.csv rename to examples/Network Investment/d.csv diff --git a/docs/source/examples/Network Investment/figure.drawio b/examples/Network Investment/figure.drawio similarity index 100% rename from docs/source/examples/Network Investment/figure.drawio rename to examples/Network Investment/figure.drawio diff --git a/docs/source/examples/Network Investment/solar.csv b/examples/Network Investment/solar.csv similarity index 100% rename from docs/source/examples/Network Investment/solar.csv rename to examples/Network Investment/solar.csv diff --git a/docs/source/examples/Storage/Storage.ipynb b/examples/Storage/Storage.ipynb similarity index 100% rename from docs/source/examples/Storage/Storage.ipynb rename to examples/Storage/Storage.ipynb diff --git a/docs/source/examples/Storage/figure.drawio b/examples/Storage/figure.drawio similarity index 100% rename from docs/source/examples/Storage/figure.drawio rename to examples/Storage/figure.drawio diff --git a/docs/source/examples/Workflow Advanced/Workflow Advanced.ipynb b/examples/Workflow Advanced/Workflow Advanced.ipynb similarity index 100% rename from docs/source/examples/Workflow Advanced/Workflow Advanced.ipynb rename to examples/Workflow Advanced/Workflow Advanced.ipynb diff --git a/docs/source/examples/Workflow/Workflow.ipynb b/examples/Workflow/Workflow.ipynb similarity index 100% rename from docs/source/examples/Workflow/Workflow.ipynb rename to examples/Workflow/Workflow.ipynb diff --git a/docs/source/examples/requirements.txt b/examples/requirements.txt similarity index 100% rename from docs/source/examples/requirements.txt rename to examples/requirements.txt diff --git a/docs/source/examples/utils.py b/examples/utils.py similarity index 94% rename from docs/source/examples/utils.py rename to examples/utils.py index ec0531f..132cc6d 100644 --- a/docs/source/examples/utils.py +++ b/examples/utils.py @@ -11,10 +11,10 @@ import nbformat import os -from nbconvert import HTMLExporter +from nbconvert import RSTExporter from nbconvert.preprocessors import ExecutePreprocessor -exporter = HTMLExporter() +exporter = RSTExporter() ep = ExecutePreprocessor(timeout=600, kernel_name='python3', store_widget_state=True) @@ -73,14 +73,14 @@ def to_export(nb: nbformat, name: str, export: str): :return: None """ print('Exporting...', end=' ') - html, _ = exporter.from_notebook_node(nb) + rst, _ = exporter.from_notebook_node(nb) path = '%s/%s' % (export, name) if not os.path.exists(path): os.makedirs(path) - with open('%s/index.html' % path, 'w') as f: - f.write(html) + with open('%s/%s.rst' % (path, name), 'w') as f: + f.write(rst) print('OK', end=' ') From 010ffb5e1370d25ff86b9564a2ce2ff4888975f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Fri, 2 Oct 2020 14:26:29 +0200 Subject: [PATCH 35/38] refactor all projects requirements --- .github/workflows/main.yml | 5 +---- .github/workflows/prod.yml | 12 +----------- .github/workflows/staging.yml | 4 +--- .readthedocs.yml | 3 +-- examples/requirements.txt | 1 - requirements.dev.txt | 6 ++++++ docs/requirements.txt => requirements.docs.txt | 4 ++-- requirements.test.txt | 3 +++ 8 files changed, 15 insertions(+), 23 deletions(-) create mode 100644 requirements.dev.txt rename docs/requirements.txt => requirements.docs.txt (57%) create mode 100644 requirements.test.txt diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 65ce3c1..3795a44 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,21 +23,18 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements.test.txt - name: Lint with flake8 if: matrix.python-version == 3.7 && matrix.os == 'ubuntu-latest' run: | - pip install flake8 # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with unittest run: | - pip install coverage coverage run --source=./hadar -m unittest discover tests coverage xml -i - - name: SonarCloud Scan if: matrix.python-version == 3.7 && matrix.os == 'ubuntu-latest' uses: sonarsource/sonarcloud-github-action@master diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index 19d6248..20a2011 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -16,17 +16,9 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Lint with flake8 - run: | - pip install flake8 - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + pip install -r requirements.test.txt - name: Test with unittest run: | - pip install coverage coverage run --source=./hadar -m unittest discover tests coverage xml -i @@ -43,14 +35,12 @@ jobs: run: | git lfs pull pip install -i https://test.pypi.org/simple/ hadar - pip install jupyter click cd examples python3 utils.py --src=./ --check=./ - name: Release pypi.org run: | export PYTHONPATH=$(pwd) - pip install setuptools wheel twine python3 setup.py sdist bdist_wheel python3 -m twine upload dist/* -u __token__ -p $PYPI_PROD_TOKEN env: diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index fcdc359..f0fc1cc 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -16,7 +16,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r requirements.test.txt - name: Lint with flake8 run: | pip install flake8 @@ -26,7 +26,6 @@ jobs: flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with unittest run: | - pip install coverage coverage run --source=./hadar -m unittest discover tests coverage xml -i @@ -41,7 +40,6 @@ jobs: - name: Release test.pypi.org run: | export PYTHONPATH=$(pwd) - pip install setuptools wheel twine python3 setup.py sdist bdist_wheel python3 -m twine upload --repository-url https://test.pypi.org/legacy/ dist/* -u __token__ -p $PYPI_TEST_TOKEN env: diff --git a/.readthedocs.yml b/.readthedocs.yml index 3902bdc..dd8d74d 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -6,8 +6,7 @@ sphinx: python: version: 3 install: - - requirements: requirements.txt - - requirements: docs/requirements.txt + - requirements: requirements.docs.txt - method: pip path: . system_packages: true diff --git a/examples/requirements.txt b/examples/requirements.txt index 37f0d78..f6682cf 100644 --- a/examples/requirements.txt +++ b/examples/requirements.txt @@ -3,4 +3,3 @@ pandas hadar plotly jupyter -click diff --git a/requirements.dev.txt b/requirements.dev.txt new file mode 100644 index 0000000..e275adf --- /dev/null +++ b/requirements.dev.txt @@ -0,0 +1,6 @@ +-e requirements.txt +jupyter +click +setuptools +wheel +twine \ No newline at end of file diff --git a/docs/requirements.txt b/requirements.docs.txt similarity index 57% rename from docs/requirements.txt rename to requirements.docs.txt index fd0f19e..44da134 100644 --- a/docs/requirements.txt +++ b/requirements.docs.txt @@ -1,6 +1,6 @@ +-e requirements.dev.txt sphinx sphinx-autobuild pydata-sphinx-theme nbsphinx -Pygments==2.6.1 -jupyter \ No newline at end of file +Pygments==2.6.1 \ No newline at end of file diff --git a/requirements.test.txt b/requirements.test.txt new file mode 100644 index 0000000..2ba440a --- /dev/null +++ b/requirements.test.txt @@ -0,0 +1,3 @@ +-e requirements.dev.txt +coverage +flake8 \ No newline at end of file From 59442a093207756bd89da9db7fe00d5a915cbdb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Fri, 2 Oct 2020 14:29:00 +0200 Subject: [PATCH 36/38] refactor all projects requirements --- requirements.dev.txt | 2 +- requirements.docs.txt | 2 +- requirements.test.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.dev.txt b/requirements.dev.txt index e275adf..8f3e008 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -1,4 +1,4 @@ --e requirements.txt +-r requirements.txt jupyter click setuptools diff --git a/requirements.docs.txt b/requirements.docs.txt index 44da134..f0825f1 100644 --- a/requirements.docs.txt +++ b/requirements.docs.txt @@ -1,4 +1,4 @@ --e requirements.dev.txt +-r requirements.dev.txt sphinx sphinx-autobuild pydata-sphinx-theme diff --git a/requirements.test.txt b/requirements.test.txt index 2ba440a..938c181 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,3 +1,3 @@ --e requirements.dev.txt +-r requirements.dev.txt coverage flake8 \ No newline at end of file From 477a42ff4a34644a3d7148716c9e1394ef74a6bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Fri, 2 Oct 2020 15:25:57 +0200 Subject: [PATCH 37/38] use black formatter --- .github/workflows/main.yml | 9 +- .github/workflows/staging.yml | 9 +- hadar/__init__.py | 27 +- hadar/analyzer/__init__.py | 1 - hadar/analyzer/result.py | 432 ++++++++++------- hadar/optimizer/__init__.py | 1 - hadar/optimizer/domain/__init__.py | 1 - hadar/optimizer/domain/input.py | 332 +++++++++---- hadar/optimizer/domain/numeric.py | 63 ++- hadar/optimizer/domain/output.py | 118 +++-- hadar/optimizer/lp/__init__.py | 1 - hadar/optimizer/lp/domain.py | 109 ++++- hadar/optimizer/lp/mapper.py | 172 +++++-- hadar/optimizer/lp/optimizer.py | 191 ++++++-- hadar/optimizer/optimizer.py | 8 +- hadar/optimizer/remote/__init__.py | 1 - hadar/optimizer/remote/optimizer.py | 30 +- hadar/optimizer/utils.py | 13 +- hadar/viewer/__init__.py | 1 - hadar/viewer/abc.py | 702 ++++++++++++++++++++++------ hadar/viewer/html.py | 284 ++++++++--- hadar/workflow/__init__.py | 1 - hadar/workflow/pipeline.py | 88 +++- hadar/workflow/shuffler.py | 15 +- requirements.dev.txt | 3 +- requirements.test.txt | 3 +- 26 files changed, 1925 insertions(+), 690 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3795a44..b480456 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,17 +20,12 @@ jobs: uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} + - uses: psf/black@stable + if: matrix.python-version == 3.7 && matrix.os == 'ubuntu-latest' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements.test.txt - - name: Lint with flake8 - if: matrix.python-version == 3.7 && matrix.os == 'ubuntu-latest' - run: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with unittest run: | coverage run --source=./hadar -m unittest discover tests diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index f0fc1cc..19f4343 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -17,13 +17,8 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.test.txt - - name: Lint with flake8 - run: | - pip install flake8 - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + - name: Check with black + uses: psf/black@stable - name: Test with unittest run: | coverage run --source=./hadar -m unittest discover tests diff --git a/hadar/__init__.py b/hadar/__init__.py index f49883a..8c033af 100644 --- a/hadar/__init__.py +++ b/hadar/__init__.py @@ -9,29 +9,40 @@ import os import sys -from .workflow.pipeline import RestrictedPlug, FreePlug, Stage, FocusStage, Drop, Rename, Fault, RepeatScenario, ToShuffler, Clip +from .workflow.pipeline import ( + RestrictedPlug, + FreePlug, + Stage, + FocusStage, + Drop, + Rename, + Fault, + RepeatScenario, + ToShuffler, + Clip, +) from .workflow.shuffler import Shuffler from .optimizer.domain.input import Study from .optimizer.optimizer import LPOptimizer, RemoteOptimizer from .viewer.html import HTMLPlotting from .analyzer.result import ResultAnalyzer -__version__ = '0.5.0' +__version__ = "0.5.0" -level = os.getenv('HADAR_LOG', 'WARNING') +level = os.getenv("HADAR_LOG", "WARNING") -if level == 'INFO': +if level == "INFO": level = logging.INFO -elif level == 'DEBUG': +elif level == "DEBUG": level = logging.DEBUG -elif level == 'WARNING': +elif level == "WARNING": level = logging.WARNING -elif level == 'ERROR': +elif level == "ERROR": level = logging.ERROR else: level = logging.WARNING -formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") handler = logging.StreamHandler(stream=sys.stdout) handler.setFormatter(formatter) logging.basicConfig(level=level, handlers=[handler]) diff --git a/hadar/analyzer/__init__.py b/hadar/analyzer/__init__.py index 84711aa..f76a769 100644 --- a/hadar/analyzer/__init__.py +++ b/hadar/analyzer/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/hadar/analyzer/result.py b/hadar/analyzer/result.py index d9a392b..01c33e8 100644 --- a/hadar/analyzer/result.py +++ b/hadar/analyzer/result.py @@ -14,9 +14,9 @@ from hadar.optimizer.domain.input import Study from hadar.optimizer.domain.output import Result -__all__ = ['ResultAnalyzer', 'NetworkFluentAPISelector'] +__all__ = ["ResultAnalyzer", "NetworkFluentAPISelector"] -T = TypeVar('T') +T = TypeVar("T") class Index(Generic[T]): @@ -68,56 +68,56 @@ class ProdIndex(Index[str]): """Index implementation to filter productions""" def __init__(self, index): - Index.__init__(self, column='name', index=index) + Index.__init__(self, column="name", index=index) class ConsIndex(Index[str]): """ Index implementation to filter consumptions""" def __init__(self, index): - Index.__init__(self, column='name', index=index) + Index.__init__(self, column="name", index=index) class StorIndex(Index[str]): """ Index implementation to filter storage""" def __init__(self, index): - Index.__init__(self, column='name', index=index) + Index.__init__(self, column="name", index=index) class LinkIndex(Index[str]): """Index implementation to filter destination node""" def __init__(self, index): - Index.__init__(self, column='dest', index=index) + Index.__init__(self, column="dest", index=index) class SrcConverter(Index[str]): """Index implementation to filter source converter""" def __init__(self, index): - Index.__init__(self, column='name', index=index) + Index.__init__(self, column="name", index=index) class DestConverter(Index[str]): """Index implementation to filter destination converter""" def __init__(self, index): - Index.__init__(self, column='name', index=index) + Index.__init__(self, column="name", index=index) class NodeIndex(Index[str]): """Index implementation to filter node""" def __init__(self, index): - Index.__init__(self, column='node', index=index) + Index.__init__(self, column="node", index=index) class NetworkIndex(Index[str]): """Index implementation fo filter network""" def __init__(self, index): - Index.__init__(self, column='network', index=index) + Index.__init__(self, column="network", index=index) class IntIndex(Index[int]): @@ -143,14 +143,14 @@ class TimeIndex(IntIndex): """Index implementation to filter by time step""" def __init__(self, index): - IntIndex.__init__(self, column='t', index=index) + IntIndex.__init__(self, column="t", index=index) class ScnIndex(IntIndex): """index implementation to filter by scenario""" def __init__(self, index): - IntIndex.__init__(self, column='scn', index=index) + IntIndex.__init__(self, column="scn", index=index) class ResultAnalyzer: @@ -172,8 +172,12 @@ def __init__(self, study: Study, result: Result): self.production = ResultAnalyzer._build_production(self.study, self.result) self.storage = ResultAnalyzer._build_storage(self.study, self.result) self.link = ResultAnalyzer._build_link(self.study, self.result) - self.src_converter = ResultAnalyzer._build_src_converter(self.study, self.result) - self.dest_converter = ResultAnalyzer._build_dest_converter(self.study, self.result) + self.src_converter = ResultAnalyzer._build_src_converter( + self.study, self.result + ) + self.dest_converter = ResultAnalyzer._build_dest_converter( + self.study, self.result + ) @staticmethod def _build_consumption(study: Study, result: Result): @@ -184,29 +188,39 @@ def _build_consumption(study: Study, result: Result): h = study.horizon scn = study.nb_scn - elements = sum([sum([len(n.consumptions) for n in net.nodes.values()]) for net in study.networks.values()]) + elements = sum( + [ + sum([len(n.consumptions) for n in net.nodes.values()]) + for net in study.networks.values() + ] + ) size = scn * h * elements - cons = {'cost': np.empty(size, dtype=float), 'asked': np.empty(size, dtype=float), - 'given': np.empty(size, dtype=float), - 'name': np.empty(size, dtype=str), 'node': np.empty(size, dtype=str), - 'network': np.empty(size, dtype=str), - 't': np.empty(size, dtype=float), 'scn': np.empty(size, dtype=float)} + cons = { + "cost": np.empty(size, dtype=float), + "asked": np.empty(size, dtype=float), + "given": np.empty(size, dtype=float), + "name": np.empty(size, dtype=str), + "node": np.empty(size, dtype=str), + "network": np.empty(size, dtype=str), + "t": np.empty(size, dtype=float), + "scn": np.empty(size, dtype=float), + } cons = pd.DataFrame(data=cons) n_cons = 0 for n, net in result.networks.items(): for node in net.nodes.keys(): for i, rc in enumerate(net.nodes[node].consumptions): - slices = cons.index[n_cons * h * scn: (n_cons + 1) * h * scn] + slices = cons.index[n_cons * h * scn : (n_cons + 1) * h * scn] sc = study.networks[n].nodes[node].consumptions[i] - cons.loc[slices, 'cost'] = sc.cost.flatten() - cons.loc[slices, 'name'] = rc.name - cons.loc[slices, 'node'] = node - cons.loc[slices, 'network'] = n - cons.loc[slices, 'asked'] = sc.quantity.flatten() - cons.loc[slices, 'given'] = rc.quantity.flatten() - cons.loc[slices, 't'] = np.tile(np.arange(h), scn) - cons.loc[slices, 'scn'] = np.repeat(np.arange(scn), h) + cons.loc[slices, "cost"] = sc.cost.flatten() + cons.loc[slices, "name"] = rc.name + cons.loc[slices, "node"] = node + cons.loc[slices, "network"] = n + cons.loc[slices, "asked"] = sc.quantity.flatten() + cons.loc[slices, "given"] = rc.quantity.flatten() + cons.loc[slices, "t"] = np.tile(np.arange(h), scn) + cons.loc[slices, "scn"] = np.repeat(np.arange(scn), h) n_cons += 1 @@ -220,29 +234,39 @@ def _build_production(study: Study, result: Result): """ h = study.horizon scn = study.nb_scn - elements = sum([sum([len(n.productions) for n in net.nodes.values()]) for net in study.networks.values()]) + elements = sum( + [ + sum([len(n.productions) for n in net.nodes.values()]) + for net in study.networks.values() + ] + ) size = scn * h * elements - prod = {'cost': np.empty(size, dtype=float), 'avail': np.empty(size, dtype=float), - 'used': np.empty(size, dtype=float), - 'name': np.empty(size, dtype=str), 'node': np.empty(size, dtype=str), - 'network': np.empty(size, dtype=str), - 't': np.empty(size, dtype=float), 'scn': np.empty(size, dtype=float)} + prod = { + "cost": np.empty(size, dtype=float), + "avail": np.empty(size, dtype=float), + "used": np.empty(size, dtype=float), + "name": np.empty(size, dtype=str), + "node": np.empty(size, dtype=str), + "network": np.empty(size, dtype=str), + "t": np.empty(size, dtype=float), + "scn": np.empty(size, dtype=float), + } prod = pd.DataFrame(data=prod) n_prod = 0 for n, net in result.networks.items(): for node in net.nodes.keys(): for i, rp in enumerate(net.nodes[node].productions): - slices = prod.index[n_prod * h * scn: (n_prod + 1) * h * scn] + slices = prod.index[n_prod * h * scn : (n_prod + 1) * h * scn] sp = study.networks[n].nodes[node].productions[i] - prod.loc[slices, 'cost'] = sp.cost.flatten() - prod.loc[slices, 'name'] = rp.name - prod.loc[slices, 'node'] = node - prod.loc[slices, 'network'] = n - prod.loc[slices, 'avail'] = sp.quantity.flatten() - prod.loc[slices, 'used'] = rp.quantity.flatten() - prod.loc[slices, 't'] = np.tile(np.arange(h), scn) - prod.loc[slices, 'scn'] = np.repeat(np.arange(scn), h) + prod.loc[slices, "cost"] = sp.cost.flatten() + prod.loc[slices, "name"] = rp.name + prod.loc[slices, "node"] = node + prod.loc[slices, "network"] = n + prod.loc[slices, "avail"] = sp.quantity.flatten() + prod.loc[slices, "used"] = rp.quantity.flatten() + prod.loc[slices, "t"] = np.tile(np.arange(h), scn) + prod.loc[slices, "scn"] = np.repeat(np.arange(scn), h) n_prod += 1 @@ -258,17 +282,30 @@ def _build_storage(study: Study, result: Result): """ h = study.horizon scn = study.nb_scn - elements = sum([sum([len(n.storages) for n in net.nodes.values()]) for net in study.networks.values()]) + elements = sum( + [ + sum([len(n.storages) for n in net.nodes.values()]) + for net in study.networks.values() + ] + ) size = h * scn * elements - stor = {'max_capacity': np.empty(size, dtype=float), 'capacity': np.empty(size, dtype=float), - 'max_flow_in': np.empty(size, dtype=float), 'flow_in': np.empty(size, dtype=float), - 'max_flow_out': np.empty(size, dtype=float), 'flow_out': np.empty(size, dtype=float), - 'cost': np.empty(size, dtype=float), - 'init_capacity': np.empty(size, dtype=float), 'eff': np.empty(size, dtype=float), - 'name': np.empty(size, dtype=str), 'node': np.empty(size, dtype=str), - 'network': np.empty(size, dtype=str), - 't': np.empty(size, dtype=float), 'scn': np.empty(size, dtype=float)} + stor = { + "max_capacity": np.empty(size, dtype=float), + "capacity": np.empty(size, dtype=float), + "max_flow_in": np.empty(size, dtype=float), + "flow_in": np.empty(size, dtype=float), + "max_flow_out": np.empty(size, dtype=float), + "flow_out": np.empty(size, dtype=float), + "cost": np.empty(size, dtype=float), + "init_capacity": np.empty(size, dtype=float), + "eff": np.empty(size, dtype=float), + "name": np.empty(size, dtype=str), + "node": np.empty(size, dtype=str), + "network": np.empty(size, dtype=str), + "t": np.empty(size, dtype=float), + "scn": np.empty(size, dtype=float), + } stor = pd.DataFrame(data=stor) @@ -276,23 +313,23 @@ def _build_storage(study: Study, result: Result): for n, net in result.networks.items(): for node in net.nodes.keys(): for i, c in enumerate(net.nodes[node].storages): - slices = stor.index[n_stor * h * scn: (n_stor + 1) * h * scn] + slices = stor.index[n_stor * h * scn : (n_stor + 1) * h * scn] study_stor = study.networks[n].nodes[node].storages[i] - stor.loc[slices, 'max_capacity'] = study_stor.capacity.flatten() - stor.loc[slices, 'capacity'] = c.capacity.flatten() - stor.loc[slices, 'max_flow_in'] = study_stor.flow_in.flatten() - stor.loc[slices, 'flow_in'] = c.flow_in.flatten() - stor.loc[slices, 'max_flow_out'] = study_stor.flow_out.flatten() - stor.loc[slices, 'flow_out'] = c.flow_out.flatten() - stor.loc[slices, 'cost'] = study_stor.cost.flatten() - stor.loc[slices, 'init_capacity'] = study_stor.init_capacity - stor.loc[slices, 'eff'] = study_stor.eff.flatten() - stor.loc[slices, 'network'] = n - stor.loc[slices, 'name'] = c.name - stor.loc[slices, 'node'] = node - stor.loc[slices, 't'] = np.tile(np.arange(h), scn) - stor.loc[slices, 'scn'] = np.repeat(np.arange(scn), h) + stor.loc[slices, "max_capacity"] = study_stor.capacity.flatten() + stor.loc[slices, "capacity"] = c.capacity.flatten() + stor.loc[slices, "max_flow_in"] = study_stor.flow_in.flatten() + stor.loc[slices, "flow_in"] = c.flow_in.flatten() + stor.loc[slices, "max_flow_out"] = study_stor.flow_out.flatten() + stor.loc[slices, "flow_out"] = c.flow_out.flatten() + stor.loc[slices, "cost"] = study_stor.cost.flatten() + stor.loc[slices, "init_capacity"] = study_stor.init_capacity + stor.loc[slices, "eff"] = study_stor.eff.flatten() + stor.loc[slices, "network"] = n + stor.loc[slices, "name"] = c.name + stor.loc[slices, "node"] = node + stor.loc[slices, "t"] = np.tile(np.arange(h), scn) + stor.loc[slices, "scn"] = np.repeat(np.arange(scn), h) return stor @@ -304,30 +341,40 @@ def _build_link(study: Study, result: Result): """ h = study.horizon scn = study.nb_scn - elements = sum([sum([len(n.links) for n in net.nodes.values()]) for net in study.networks.values()]) + elements = sum( + [ + sum([len(n.links) for n in net.nodes.values()]) + for net in study.networks.values() + ] + ) size = h * scn * elements - link = {'cost': np.empty(size, dtype=float), 'avail': np.empty(size, dtype=float), - 'used': np.empty(size, dtype=float), - 'node': np.empty(size, dtype=str), 'dest': np.empty(size, dtype=str), - 'network': np.empty(size, dtype=str), - 't': np.empty(size, dtype=float), 'scn': np.empty(size, dtype=float)} + link = { + "cost": np.empty(size, dtype=float), + "avail": np.empty(size, dtype=float), + "used": np.empty(size, dtype=float), + "node": np.empty(size, dtype=str), + "dest": np.empty(size, dtype=str), + "network": np.empty(size, dtype=str), + "t": np.empty(size, dtype=float), + "scn": np.empty(size, dtype=float), + } link = pd.DataFrame(data=link) n_link = 0 for n, net in result.networks.items(): for node in net.nodes.keys(): for i, rl in enumerate(net.nodes[node].links): - slices = link.index[n_link * h * scn: (n_link + 1) * h * scn] + slices = link.index[n_link * h * scn : (n_link + 1) * h * scn] sl = study.networks[n].nodes[node].links[i] - link.loc[slices, 'cost'] = sl.cost.flatten() - link.loc[slices, 'dest'] = rl.dest - link.loc[slices, 'node'] = node - link.loc[slices, 'network'] = n - link.loc[slices, 'avail'] = sl.quantity.flatten() - link.loc[slices, 'used'] = rl.quantity.flatten() - link.loc[slices, 't'] = np.tile(np.arange(h), scn) - link.loc[slices, 'scn'] = np.repeat(np.arange(scn), h) + link.loc[slices, "cost"] = sl.cost.flatten() + link.loc[slices, "dest"] = rl.dest + link.loc[slices, "node"] = node + link.loc[slices, "network"] = n + link.loc[slices, "avail"] = sl.quantity.flatten() + link.loc[slices, "used"] = rl.quantity.flatten() + link.loc[slices, "t"] = np.tile(np.arange(h), scn) + link.loc[slices, "scn"] = np.repeat(np.arange(scn), h) n_link += 1 @@ -340,21 +387,26 @@ def _build_dest_converter(study: Study, result: Result): elements = sum([len(v.src_ratios) for v in study.converters.values()]) size = h * scn * elements - dest_conv = {'name': np.empty(size, dtype=str), 'network': np.empty(size, dtype=str), - 'node': np.empty(size, dtype=str), 'flow': np.empty(size, dtype=float), - 'cost': np.empty(size, dtype=float), 'max': np.empty(size, dtype=float)} + dest_conv = { + "name": np.empty(size, dtype=str), + "network": np.empty(size, dtype=str), + "node": np.empty(size, dtype=str), + "flow": np.empty(size, dtype=float), + "cost": np.empty(size, dtype=float), + "max": np.empty(size, dtype=float), + } dest_conv = pd.DataFrame(data=dest_conv) for i, (name, v) in enumerate(study.converters.items()): - slices = dest_conv.index[i * h * scn: (i + 1) * h * scn] - dest_conv.loc[slices, 'name'] = v.name - dest_conv.loc[slices, 'cost'] = v.cost.flatten() - dest_conv.loc[slices, 'max'] = v.max.flatten() - dest_conv.loc[slices, 'network'] = v.dest_network - dest_conv.loc[slices, 'node'] = v.dest_node - dest_conv.loc[slices, 'flow'] = result.converters[name].flow_dest.flatten() - dest_conv.loc[slices, 't'] = np.tile(np.arange(h), scn) - dest_conv.loc[slices, 'scn'] = np.repeat(np.arange(scn), h) + slices = dest_conv.index[i * h * scn : (i + 1) * h * scn] + dest_conv.loc[slices, "name"] = v.name + dest_conv.loc[slices, "cost"] = v.cost.flatten() + dest_conv.loc[slices, "max"] = v.max.flatten() + dest_conv.loc[slices, "network"] = v.dest_network + dest_conv.loc[slices, "node"] = v.dest_node + dest_conv.loc[slices, "flow"] = result.converters[name].flow_dest.flatten() + dest_conv.loc[slices, "t"] = np.tile(np.arange(h), scn) + dest_conv.loc[slices, "scn"] = np.repeat(np.arange(scn), h) return dest_conv @@ -365,9 +417,14 @@ def _build_src_converter(study: Study, result: Result): elements = sum([len(v.src_ratios) for v in study.converters.values()]) size = h * scn * elements - src_conv = {'name': np.empty(size, dtype=str), 'network': np.empty(size, dtype=str), - 'node': np.empty(size, dtype=str), 'ratio': np.empty(size, dtype=float), - 'flow': np.empty(size, dtype=float), 'max': np.empty(size, dtype=float)} + src_conv = { + "name": np.empty(size, dtype=str), + "network": np.empty(size, dtype=str), + "node": np.empty(size, dtype=str), + "ratio": np.empty(size, dtype=float), + "flow": np.empty(size, dtype=float), + "max": np.empty(size, dtype=float), + } src_conv = pd.DataFrame(data=src_conv) s = 0 @@ -375,28 +432,33 @@ def _build_src_converter(study: Study, result: Result): src_size = len(v.src_ratios) e = s + h * scn * src_size slices = src_conv.index[s:e] - src_conv.loc[slices, 'name'] = v.name - src_conv.loc[slices, 't'] = np.tile(np.arange(h), scn * src_size) - src_conv.loc[slices, 'scn'] = np.repeat(np.arange(scn), h * src_size) + src_conv.loc[slices, "name"] = v.name + src_conv.loc[slices, "t"] = np.tile(np.arange(h), scn * src_size) + src_conv.loc[slices, "scn"] = np.repeat(np.arange(scn), h * src_size) for i_src, (net, node) in enumerate(v.src_ratios.keys()): e = s + h * scn * (i_src + 1) slices = src_conv.index[s:e] - src_conv.loc[slices, 'network'] = net - src_conv.loc[slices, 'node'] = node - src_conv.loc[slices, 'max'] = v.max.flatten() - src_conv.loc[slices, 'ratio'] = v.src_ratios[(net, node)].flatten() - src_conv.loc[slices, 'flow'] = result.converters[name].flow_src[(net, node)].flatten() + src_conv.loc[slices, "network"] = net + src_conv.loc[slices, "node"] = node + src_conv.loc[slices, "max"] = v.max.flatten() + src_conv.loc[slices, "ratio"] = v.src_ratios[(net, node)].flatten() + src_conv.loc[slices, "flow"] = ( + result.converters[name].flow_src[(net, node)].flatten() + ) s = e s = e - src_conv.loc[:, 'max'] /= src_conv[ - 'ratio'] # max value is for output. Need to divide by ratio to find max for src + src_conv.loc[:, "max"] /= src_conv[ + "ratio" + ] # max value is for output. Need to divide by ratio to find max for src return src_conv @staticmethod - def _remove_useless_index_level(df: pd.DataFrame, indexes: List[Index]) -> pd.DataFrame: + def _remove_useless_index_level( + df: pd.DataFrame, indexes: List[Index] + ) -> pd.DataFrame: """ If top index level has only on element then remove this index level. Do it recursively. @@ -445,7 +507,9 @@ def _assert_index(indexes: List[Index], type: Type): :return: true if at least one type is in list False else """ if not ResultAnalyzer.check_index(indexes, type): - raise ValueError('Indexes must contain a {}'.format(type.__class__.__name__)) + raise ValueError( + "Indexes must contain a {}".format(type.__class__.__name__) + ) def filter(self, indexes: List[Index]) -> pd.DataFrame: """ @@ -474,7 +538,7 @@ def filter(self, indexes: List[Index]) -> pd.DataFrame: if ResultAnalyzer.check_index(indexes, DestConverter): return ResultAnalyzer._pivot(indexes, self.dest_converter) - def network(self, name='default'): + def network(self, name="default"): """ Entry point for fluent api :param name: network name. 'default' as default @@ -505,19 +569,27 @@ def get_elements_inside(self, node: str = None, network: str = None): # If node is provided but no network set network as 'default' if node and network is None: - network = 'default' + network = "default" n = self.study.networks[network].nodes[node] - return np.array([ - len(n.consumptions), - len(n.productions), - len(n.storages), - len(n.links), - sum((network, node) in conv.src_ratios for conv in self.study.converters.values()), - sum((network == conv.dest_network) and (node == conv.dest_node) for conv in self.study.converters.values()) - ]) - - def get_balance(self, node: str, network: str = 'default') -> np.ndarray: + return np.array( + [ + len(n.consumptions), + len(n.productions), + len(n.storages), + len(n.links), + sum( + (network, node) in conv.src_ratios + for conv in self.study.converters.values() + ), + sum( + (network == conv.dest_network) and (node == conv.dest_node) + for conv in self.study.converters.values() + ), + ] + ) + + def get_balance(self, node: str, network: str = "default") -> np.ndarray: """ Compute balance over time on asked node. @@ -527,15 +599,19 @@ def get_balance(self, node: str, network: str = 'default') -> np.ndarray: """ balance = np.zeros((self.nb_scn, self.study.horizon)) - mask = (self.link['dest'] == node) & (self.link['network'] == network) - im = pd.pivot_table(self.link[mask][['used', 'scn', 't']], index=['scn', 't'], aggfunc=np.sum) + mask = (self.link["dest"] == node) & (self.link["network"] == network) + im = pd.pivot_table( + self.link[mask][["used", "scn", "t"]], index=["scn", "t"], aggfunc=np.sum + ) if im.size > 0: - balance += -im['used'].values.reshape(self.nb_scn, self.horizon) + balance += -im["used"].values.reshape(self.nb_scn, self.horizon) - mask = (self.link['node'] == node) & (self.link['network'] == network) - exp = pd.pivot_table(self.link[mask][['used', 'scn', 't']], index=['scn', 't'], aggfunc=np.sum) + mask = (self.link["node"] == node) & (self.link["network"] == network) + exp = pd.pivot_table( + self.link[mask][["used", "scn", "t"]], index=["scn", "t"], aggfunc=np.sum + ) if exp.size > 0: - balance += exp['used'].values.reshape(self.nb_scn, self.horizon) + balance += exp["used"].values.reshape(self.nb_scn, self.horizon) return balance def get_cost(self, node: str = None, network: str = None) -> np.ndarray: @@ -548,65 +624,100 @@ def get_cost(self, node: str = None, network: str = None) -> np.ndarray: """ cost = np.zeros((self.nb_scn, self.horizon)) c, p, s, l, _, v = self.get_elements_inside(node, network) - network = 'default' if node and network is None else network + network = "default" if node and network is None else network if c: cons = self.network(network).node(node).scn().time().consumption() - cost += ((cons['asked'] - cons['given']) * cons['cost']).groupby(axis=0, level=('scn', 't')) \ - .sum().sort_index(level=(0, 1)).values.reshape(self.nb_scn, self.horizon) + cost += ( + ((cons["asked"] - cons["given"]) * cons["cost"]) + .groupby(axis=0, level=("scn", "t")) + .sum() + .sort_index(level=(0, 1)) + .values.reshape(self.nb_scn, self.horizon) + ) if p: prod = self.network(network).node(node).scn().time().production() - cost += (prod['used'] * prod['cost']).groupby(axis=0, level=('scn', 't')) \ - .sum().sort_index(level=(0, 1)).values.reshape(self.nb_scn, self.horizon) + cost += ( + (prod["used"] * prod["cost"]) + .groupby(axis=0, level=("scn", "t")) + .sum() + .sort_index(level=(0, 1)) + .values.reshape(self.nb_scn, self.horizon) + ) if s: stor = self.network(network).node(node).scn().time().storage() - cost += (stor['capacity'] * stor['cost']).groupby(axis=0, level=('scn', 't')) \ - .sum().sort_index(level=(0, 1)).values.reshape(self.nb_scn, self.horizon) + cost += ( + (stor["capacity"] * stor["cost"]) + .groupby(axis=0, level=("scn", "t")) + .sum() + .sort_index(level=(0, 1)) + .values.reshape(self.nb_scn, self.horizon) + ) if l: link = self.network(network).node(node).scn().time().link() - cost += (link['used'] * link['cost']).groupby(axis=0, level=('scn', 't')) \ - .sum().sort_index(level=(0, 1)).values.reshape(self.nb_scn, self.horizon) + cost += ( + (link["used"] * link["cost"]) + .groupby(axis=0, level=("scn", "t")) + .sum() + .sort_index(level=(0, 1)) + .values.reshape(self.nb_scn, self.horizon) + ) if v: conv = self.network(network).node(node).scn().time().from_converter() - cost += (conv['flow'] * conv['cost']).groupby(axis=0, level=('scn', 't')) \ - .sum().sort_index(level=(0, 1)).values.reshape(self.nb_scn, self.horizon) + cost += ( + (conv["flow"] * conv["cost"]) + .groupby(axis=0, level=("scn", "t")) + .sum() + .sort_index(level=(0, 1)) + .values.reshape(self.nb_scn, self.horizon) + ) return cost - def get_rac(self, network='default') -> np.ndarray: + def get_rac(self, network="default") -> np.ndarray: """ Compute Remain Availabale Capacities on network. :param network: selecto network to compute. Default is default. :return: matrix (scn, time) """ + def fill_width_zeros(arr: np.ndarray) -> np.ndarray: return np.zeros((self.nb_scn, self.horizon)) if arr.size == 0 else arr - prod_used = self.production[self.production['network'] == network] \ - .drop(['avail', 'cost'], axis=1) \ - .pivot_table(index='scn', columns='t', aggfunc=np.sum) \ + + prod_used = ( + self.production[self.production["network"] == network] + .drop(["avail", "cost"], axis=1) + .pivot_table(index="scn", columns="t", aggfunc=np.sum) .values + ) prod_used = fill_width_zeros(prod_used) - prod_avail = self.production[self.production['network'] == network] \ - .drop(['used', 'cost'], axis=1) \ - .pivot_table(index='scn', columns='t', aggfunc=np.sum) \ + prod_avail = ( + self.production[self.production["network"] == network] + .drop(["used", "cost"], axis=1) + .pivot_table(index="scn", columns="t", aggfunc=np.sum) .values + ) prod_avail = fill_width_zeros(prod_avail) - cons_asked = self.consumption[self.consumption['network'] == network] \ - .drop(['given', 'cost'], axis=1) \ - .pivot_table(index='scn', columns='t', aggfunc=np.sum) \ + cons_asked = ( + self.consumption[self.consumption["network"] == network] + .drop(["given", "cost"], axis=1) + .pivot_table(index="scn", columns="t", aggfunc=np.sum) .values + ) cons_asked = fill_width_zeros(cons_asked) - cons_given = self.consumption[self.consumption['network'] == network] \ - .drop(['asked', 'cost'], axis=1) \ - .pivot_table(index='scn', columns='t', aggfunc=np.sum) \ + cons_given = ( + self.consumption[self.consumption["network"] == network] + .drop(["asked", "cost"], axis=1) + .pivot_table(index="scn", columns="t", aggfunc=np.sum) .values + ) cons_given = fill_width_zeros(cons_given) return (prod_avail - prod_used) - (cons_asked - cons_given) @@ -629,7 +740,7 @@ def nb_scn(self) -> int: """ return self.study.nb_scn - def nodes(self, network: str = 'default') -> List[str]: + def nodes(self, network: str = "default") -> List[str]: """ Shortcut to get list of node names @@ -649,18 +760,21 @@ class NetworkFluentAPISelector: - join is unique only one element of node, time, scn are expected for each query - production, consumption and link are excluded themself, only on of them are expected for each query """ + FULL_DESCRIPTION = 5 # Need 5 indexes to describe completely a query def __init__(self, indexes: List[Index], analyzer: ResultAnalyzer): self.indexes = indexes self.analyzer = analyzer - if not ResultAnalyzer.check_index(indexes, ConsIndex) \ - and not ResultAnalyzer.check_index(indexes, ProdIndex) \ - and not ResultAnalyzer.check_index(indexes, StorIndex) \ - and not ResultAnalyzer.check_index(indexes, LinkIndex) \ - and not ResultAnalyzer.check_index(indexes, SrcConverter) \ - and not ResultAnalyzer.check_index(indexes, DestConverter): + if ( + not ResultAnalyzer.check_index(indexes, ConsIndex) + and not ResultAnalyzer.check_index(indexes, ProdIndex) + and not ResultAnalyzer.check_index(indexes, StorIndex) + and not ResultAnalyzer.check_index(indexes, LinkIndex) + and not ResultAnalyzer.check_index(indexes, SrcConverter) + and not ResultAnalyzer.check_index(indexes, DestConverter) + ): self.consumption = lambda x=None: self._append(ConsIndex(x)) self.production = lambda x=None: self._append(ProdIndex(x)) self.link = lambda x=None: self._append(LinkIndex(x)) diff --git a/hadar/optimizer/__init__.py b/hadar/optimizer/__init__.py index 84711aa..f76a769 100644 --- a/hadar/optimizer/__init__.py +++ b/hadar/optimizer/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/hadar/optimizer/domain/__init__.py b/hadar/optimizer/domain/__init__.py index 84711aa..f76a769 100644 --- a/hadar/optimizer/domain/__init__.py +++ b/hadar/optimizer/domain/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/hadar/optimizer/domain/input.py b/hadar/optimizer/domain/input.py index 7e3cb09..035fc2b 100644 --- a/hadar/optimizer/domain/input.py +++ b/hadar/optimizer/domain/input.py @@ -10,8 +10,18 @@ import numpy as np -__all__ = ['Consumption', 'Link', 'Production', 'Storage', 'Converter', 'InputNetwork', 'InputNode', 'Study', - 'NetworkFluentAPISelector', 'NodeFluentAPISelector'] +__all__ = [ + "Consumption", + "Link", + "Production", + "Storage", + "Converter", + "InputNetwork", + "InputNode", + "Study", + "NetworkFluentAPISelector", + "NodeFluentAPISelector", +] import hadar from hadar.optimizer.domain.numeric import NumericalValue, NumericalValueFactory @@ -25,7 +35,7 @@ class Consumption(JSON): Consumption element. """ - def __init__(self, quantity: NumericalValue, cost: NumericalValue, name: str = ''): + def __init__(self, quantity: NumericalValue, cost: NumericalValue, name: str = ""): """ Create consumption. @@ -39,8 +49,8 @@ def __init__(self, quantity: NumericalValue, cost: NumericalValue, name: str = ' @staticmethod def from_json(dict, factory=None): - dict['cost'] = factory.create(dict['cost']) - dict['quantity'] = factory.create(dict['quantity']) + dict["cost"] = factory.create(dict["cost"]) + dict["quantity"] = factory.create(dict["quantity"]) return Consumption(**dict) @@ -48,7 +58,10 @@ class Production(JSON): """ Production element """ - def __init__(self, quantity: NumericalValue, cost: NumericalValue, name: str = 'in'): + + def __init__( + self, quantity: NumericalValue, cost: NumericalValue, name: str = "in" + ): """ Create production @@ -62,8 +75,8 @@ def __init__(self, quantity: NumericalValue, cost: NumericalValue, name: str = ' @staticmethod def from_json(dict, factory=None): - dict['cost'] = factory.create(dict['cost']) - dict['quantity'] = factory.create(dict['quantity']) + dict["cost"] = factory.create(dict["cost"]) + dict["quantity"] = factory.create(dict["quantity"]) return Production(**dict) @@ -71,8 +84,17 @@ class Storage(JSON): """ Storage element """ - def __init__(self, name, capacity: NumericalValue, flow_in: NumericalValue, flow_out: NumericalValue, - cost: NumericalValue, init_capacity: int, eff: NumericalValue): + + def __init__( + self, + name, + capacity: NumericalValue, + flow_in: NumericalValue, + flow_out: NumericalValue, + cost: NumericalValue, + init_capacity: int, + eff: NumericalValue, + ): """ Create storage. @@ -93,11 +115,11 @@ def __init__(self, name, capacity: NumericalValue, flow_in: NumericalValue, flow @staticmethod def from_json(dict, factory=None): - dict['cost'] = factory.create(dict['cost']) - dict['capacity'] = factory.create(dict['capacity']) - dict['flow_in'] = factory.create(dict['flow_in']) - dict['flow_out'] = factory.create(dict['flow_out']) - dict['eff'] = factory.create(dict['eff']) + dict["cost"] = factory.create(dict["cost"]) + dict["capacity"] = factory.create(dict["capacity"]) + dict["flow_in"] = factory.create(dict["flow_in"]) + dict["flow_out"] = factory.create(dict["flow_out"]) + dict["eff"] = factory.create(dict["eff"]) return Storage(**dict) @@ -106,6 +128,7 @@ class Link(JSON): """ Link element """ + def __init__(self, dest: str, quantity: NumericalValue, cost: NumericalValue): """ Create link. @@ -120,8 +143,8 @@ def __init__(self, dest: str, quantity: NumericalValue, cost: NumericalValue): @staticmethod def from_json(dict, factory=None): - dict['cost'] = factory.create(dict['cost']) - dict['quantity'] = factory.create(dict['quantity']) + dict["cost"] = factory.create(dict["cost"]) + dict["quantity"] = factory.create(dict["quantity"]) return Link(**dict) @@ -129,8 +152,16 @@ class Converter(JSON): """ Converter element """ - def __init__(self, name: str, src_ratios: Dict[Tuple[str, str], NumericalValue], dest_network: str, dest_node: str, - cost: NumericalValue, max: NumericalValue): + + def __init__( + self, + name: str, + src_ratios: Dict[Tuple[str, str], NumericalValue], + dest_network: str, + dest_node: str, + cost: NumericalValue, + max: NumericalValue, + ): """ Create converter. @@ -154,7 +185,9 @@ def to_json(self) -> dict: # src_ratios has a tuple of two string as key. These forbidden by JSON. # Therefore when serialized we join these two strings with '::' to create on string as key # Ex: ('elec', 'a') --> 'elec::a' - dict['src_ratios'] = {'::'.join(k): v.to_json() for k, v in self.src_ratios.items()} + dict["src_ratios"] = { + "::".join(k): v.to_json() for k, v in self.src_ratios.items() + } return {k: JSON.convert(v) for k, v in dict.items()} @staticmethod @@ -162,17 +195,27 @@ def from_json(dict: dict, factory=None): # When deserialize, we need to split key string of src_network. # JSON doesn't accept tuple as key, so two string was joined for serialization # Ex: 'elec::a' -> ('elec', 'a') - dict['cost'] = factory.create(dict['cost']) - dict['max'] = factory.create(dict['max']) - dict['src_ratios'] = {tuple(k.split('::')): factory.create(v) for k, v in dict['src_ratios'].items()} + dict["cost"] = factory.create(dict["cost"]) + dict["max"] = factory.create(dict["max"]) + dict["src_ratios"] = { + tuple(k.split("::")): factory.create(v) + for k, v in dict["src_ratios"].items() + } return Converter(**dict) + class InputNode(JSON): """ Node element """ - def __init__(self, consumptions: List[Consumption], productions: List[Production], - storages: List[Storage], links: List[Link]): + + def __init__( + self, + consumptions: List[Consumption], + productions: List[Production], + storages: List[Storage], + links: List[Link], + ): """ Create node element. @@ -188,10 +231,16 @@ def __init__(self, consumptions: List[Consumption], productions: List[Production @staticmethod def from_json(dict, factory=None): - dict['consumptions'] = [Consumption.from_json(dict=v, factory=factory) for v in dict['consumptions']] - dict['productions'] = [Production.from_json(dict=v, factory=factory) for v in dict['productions']] - dict['storages'] = [Storage.from_json(dict=v, factory=factory) for v in dict['storages']] - dict['links'] = [Link.from_json(dict=v, factory=factory) for v in dict['links']] + dict["consumptions"] = [ + Consumption.from_json(dict=v, factory=factory) for v in dict["consumptions"] + ] + dict["productions"] = [ + Production.from_json(dict=v, factory=factory) for v in dict["productions"] + ] + dict["storages"] = [ + Storage.from_json(dict=v, factory=factory) for v in dict["storages"] + ] + dict["links"] = [Link.from_json(dict=v, factory=factory) for v in dict["links"]] return InputNode(**dict) @@ -199,6 +248,7 @@ class InputNetwork(JSON): """ Network element """ + def __init__(self, nodes: Dict[str, InputNode] = None): """ Create network element @@ -209,7 +259,10 @@ def __init__(self, nodes: Dict[str, InputNode] = None): @staticmethod def from_json(dict, factory=None): - dict['nodes'] = {k: InputNode.from_json(dict=v, factory=factory) for k, v in dict['nodes'].items()} + dict["nodes"] = { + k: InputNode.from_json(dict=v, factory=factory) + for k, v in dict["nodes"].items() + } return InputNetwork(**dict) @@ -234,27 +287,43 @@ def __init__(self, horizon: int, nb_scn: int = 1, version: str = None): def to_json(self): # remove factory from serialization - return {k: JSON.convert(v) for k, v in self.__dict__.items() if k not in ['factory']} - + return { + k: JSON.convert(v) for k, v in self.__dict__.items() if k not in ["factory"] + } @staticmethod def from_json(dict, factory=None): dict = deepcopy(dict) - study = Study(horizon=dict['horizon'], nb_scn=dict['nb_scn'], version=dict['version']) - study.networks = {k: InputNetwork.from_json(dict=v, factory=study.factory) for k, v in dict['networks'].items()} - study.converters = {k: Converter.from_json(dict=v, factory=study.factory) for k, v in dict['converters'].items()} + study = Study( + horizon=dict["horizon"], nb_scn=dict["nb_scn"], version=dict["version"] + ) + study.networks = { + k: InputNetwork.from_json(dict=v, factory=study.factory) + for k, v in dict["networks"].items() + } + study.converters = { + k: Converter.from_json(dict=v, factory=study.factory) + for k, v in dict["converters"].items() + } return study - def network(self, name='default'): + def network(self, name="default"): """ Entry point to create study with the fluent api. :return: """ self.add_network(name) - return NetworkFluentAPISelector(selector={'network': name}, study=self) + return NetworkFluentAPISelector(selector={"network": name}, study=self) - def add_link(self, network: str, src: str, dest: str, cost: NumericalValueType, quantity: NumericalValueType): + def add_link( + self, + network: str, + src: str, + dest: str, + cost: NumericalValueType, + quantity: NumericalValueType, + ): """ Add a link inside network. @@ -266,18 +335,20 @@ def add_link(self, network: str, src: str, dest: str, cost: NumericalValueType, :return: """ if src not in self.networks[network].nodes.keys(): - raise ValueError('link source must be a valid node') + raise ValueError("link source must be a valid node") if dest not in self.networks[network].nodes.keys(): - raise ValueError('link destination must be a valid node') + raise ValueError("link destination must be a valid node") if dest in [l.dest for l in self.networks[network].nodes[src].links]: - raise ValueError('link destination must be unique on a node') + raise ValueError("link destination must be unique on a node") quantity = self.factory.create(quantity) if quantity < 0: - raise ValueError('Link quantity must be positive') + raise ValueError("Link quantity must be positive") cost = self.factory.create(cost) - self.networks[network].nodes[src].links.append(Link(dest=dest, quantity=quantity, cost=cost)) + self.networks[network].nodes[src].links.append( + Link(dest=dest, quantity=quantity, cost=cost) + ) return self @@ -287,46 +358,52 @@ def add_network(self, network: str): def add_node(self, network: str, node: str): if node not in self.networks[network].nodes.keys(): - self.networks[network].nodes[node] = InputNode(consumptions=[], productions=[], links=[], storages=[]) + self.networks[network].nodes[node] = InputNode( + consumptions=[], productions=[], links=[], storages=[] + ) def _add_production(self, network: str, node: str, prod: Production): - if prod.name in [p.name for p in self.networks[network].nodes[node].productions]: - raise ValueError('production name must be unique on a node') + if prod.name in [ + p.name for p in self.networks[network].nodes[node].productions + ]: + raise ValueError("production name must be unique on a node") prod.quantity = self.factory.create(prod.quantity) if prod.quantity < 0: - raise ValueError('Production quantity must be positive') + raise ValueError("Production quantity must be positive") prod.cost = self.factory.create(prod.cost) self.networks[network].nodes[node].productions.append(prod) def _add_consumption(self, network: str, node: str, cons: Consumption): - if cons.name in [c.name for c in self.networks[network].nodes[node].consumptions]: - raise ValueError('consumption name must be unique on a node') + if cons.name in [ + c.name for c in self.networks[network].nodes[node].consumptions + ]: + raise ValueError("consumption name must be unique on a node") cons.quantity = self.factory.create(cons.quantity) if cons.quantity < 0: - raise ValueError('Consumption quantity must be positive') + raise ValueError("Consumption quantity must be positive") cons.cost = self.factory.create(cons.cost) self.networks[network].nodes[node].consumptions.append(cons) def _add_storage(self, network: str, node: str, store: Storage): if store.name in [s.name for s in self.networks[network].nodes[node].storages]: - raise ValueError('storage name must be unique on a node') + raise ValueError("storage name must be unique on a node") store.flow_in = self.factory.create(store.flow_in) store.flow_out = self.factory.create(store.flow_out) if store.flow_in < 0 or store.flow_out < 0: - raise ValueError('storage flow must be positive') + raise ValueError("storage flow must be positive") store.capacity = self.factory.create(store.capacity) if store.capacity < 0 or store.init_capacity < 0: - raise ValueError('storage capacities must be positive') + raise ValueError("storage capacities must be positive") store.eff = self.factory.create(store.eff) if store.eff < 0 or store.eff > 1: - raise ValueError('storage efficiency must be in ]0, 1[') + raise ValueError("storage efficiency must be in ]0, 1[") store.cost = self.factory.create(store.cost) @@ -334,21 +411,36 @@ def _add_storage(self, network: str, node: str, store: Storage): def _add_converter(self, name: str): if name not in [v for v in self.converters]: - self.converters[name] = Converter(name=name, src_ratios={}, dest_network='', - dest_node='', cost=0, max=0) + self.converters[name] = Converter( + name=name, src_ratios={}, dest_network="", dest_node="", cost=0, max=0 + ) - def _add_converter_src(self, name: str, network: str, node: str, ratio: NumericalValueType): + def _add_converter_src( + self, name: str, network: str, node: str, ratio: NumericalValueType + ): if (network, node) in self.converters[name].src_ratios: - raise ValueError('converter input already has node %s on network %s' % (node, network)) + raise ValueError( + "converter input already has node %s on network %s" % (node, network) + ) ratio = self.factory.create(ratio) self.converters[name].src_ratios[(network, node)] = ratio - def _set_converter_dest(self, name: str, network: str, node: str, cost: NumericalValueType, max: NumericalValueType): + def _set_converter_dest( + self, + name: str, + network: str, + node: str, + cost: NumericalValueType, + max: NumericalValueType, + ): if self.converters[name].dest_network and self.converters[name].dest_node: - raise ValueError('converter has already output set') - if network not in self.networks or node not in self.networks[network].nodes.keys(): - raise ValueError('Node %s is not present in network %s' % (node, network)) + raise ValueError("converter has already output set") + if ( + network not in self.networks + or node not in self.networks[network].nodes.keys() + ): + raise ValueError("Node %s is not present in network %s" % (node, network)) self.converters[name].dest_network = network self.converters[name].dest_node = node @@ -360,6 +452,7 @@ class NetworkFluentAPISelector: """ Network level of Fluent API Selector. """ + def __init__(self, study, selector): self.study = study self.selector = selector @@ -371,11 +464,17 @@ def node(self, name): :param name: node to select when changing level :return: NodeFluentAPISelector initialized """ - self.selector['node'] = name - self.study.add_node(network=self.selector['network'], node=name) + self.selector["node"] = name + self.study.add_node(network=self.selector["network"], node=name) return NodeFluentAPISelector(self.study, self.selector) - def link(self, src: str, dest: str, cost: NumericalValueType, quantity: NumericalValueType): + def link( + self, + src: str, + dest: str, + cost: NumericalValueType, + quantity: NumericalValueType, + ): """ Add a link on network. @@ -386,10 +485,16 @@ def link(self, src: str, dest: str, cost: NumericalValueType, quantity: Numerica :return: NetworkAPISelector with new link. """ - self.study.add_link(network=self.selector['network'], src=src, dest=dest, cost=cost, quantity=quantity) + self.study.add_link( + network=self.selector["network"], + src=src, + dest=dest, + cost=cost, + quantity=quantity, + ) return NetworkFluentAPISelector(self.study, self.selector) - def network(self, name='default'): + def network(self, name="default"): """ Go to network level. @@ -397,9 +502,16 @@ def network(self, name='default'): :return: NetworkAPISelector with selector set to 'default' """ self.study.add_network(name) - return NetworkFluentAPISelector(selector={'network': name}, study=self.study) + return NetworkFluentAPISelector(selector={"network": name}, study=self.study) - def converter(self, name: str, to_network: str, to_node: str, max: NumericalValueType, cost: NumericalValueType = 0): + def converter( + self, + name: str, + to_network: str, + to_node: str, + max: NumericalValueType, + cost: NumericalValueType = 0, + ): """ Add a converter element. @@ -411,7 +523,9 @@ def converter(self, name: str, to_network: str, to_node: str, max: NumericalValu :return: """ self.study._add_converter(name=name) - self.study._set_converter_dest(name=name, network=to_network, node=to_node, cost=cost, max=max) + self.study._set_converter_dest( + name=name, network=to_network, node=to_node, cost=cost, max=max + ) return NetworkFluentAPISelector(selector={}, study=self.study) def build(self): @@ -427,11 +541,14 @@ class NodeFluentAPISelector: """ Node level of Fluent API Selector """ + def __init__(self, study, selector): self.study = study self.selector = selector - def consumption(self, name: str, cost: NumericalValueType, quantity: NumericalValueType): + def consumption( + self, name: str, cost: NumericalValueType, quantity: NumericalValueType + ): """ Add consumption on node. @@ -440,11 +557,16 @@ def consumption(self, name: str, cost: NumericalValueType, quantity: NumericalVa :param quantity: consumption to sustain :return: NodeFluentAPISelector with new consumption """ - self.study._add_consumption(network=self.selector['network'], node=self.selector['node'], - cons=Consumption(name=name, cost=cost, quantity=quantity)) + self.study._add_consumption( + network=self.selector["network"], + node=self.selector["node"], + cons=Consumption(name=name, cost=cost, quantity=quantity), + ) return self - def production(self, name: str, cost: NumericalValueType, quantity: NumericalValueType): + def production( + self, name: str, cost: NumericalValueType, quantity: NumericalValueType + ): """ Add production on node. @@ -453,12 +575,23 @@ def production(self, name: str, cost: NumericalValueType, quantity: NumericalVal :param quantity: available capacities :return: NodeFluentAPISelector with new production """ - self.study._add_production(network=self.selector['network'], node=self.selector['node'], - prod=Production(name=name, cost=cost, quantity=quantity)) + self.study._add_production( + network=self.selector["network"], + node=self.selector["node"], + prod=Production(name=name, cost=cost, quantity=quantity), + ) return self - def storage(self, name, capacity: NumericalValueType, flow_in: NumericalValueType, flow_out: NumericalValueType, - cost: NumericalValueType = 0, init_capacity: int = 0, eff: NumericalValueType = 0.99): + def storage( + self, + name, + capacity: NumericalValueType, + flow_in: NumericalValueType, + flow_out: NumericalValueType, + cost: NumericalValueType = 0, + init_capacity: int = 0, + eff: NumericalValueType = 0.99, + ): """ Create storage. @@ -469,9 +602,19 @@ def storage(self, name, capacity: NumericalValueType, flow_in: NumericalValueTyp :param init_capacity: initial capacity level. default 0 :param eff: storage efficient (applied on input flow stored). default 0.99 """ - self.study._add_storage(network=self.selector['network'], node=self.selector['node'], - store=Storage(name=name, capacity=capacity, flow_in=flow_in, flow_out=flow_out, - cost=cost, init_capacity=init_capacity, eff=eff)) + self.study._add_storage( + network=self.selector["network"], + node=self.selector["node"], + store=Storage( + name=name, + capacity=capacity, + flow_in=flow_in, + flow_out=flow_out, + cost=cost, + init_capacity=init_capacity, + eff=eff, + ), + ) return self def node(self, name): @@ -494,9 +637,11 @@ def link(self, src: str, dest: str, cost: int, quantity: NumericalValueType): :return: NetworkAPISelector with new link. """ - return NetworkFluentAPISelector(self.study, self.selector).link(src=src, dest=dest, cost=cost, quantity=quantity) + return NetworkFluentAPISelector(self.study, self.selector).link( + src=src, dest=dest, cost=cost, quantity=quantity + ) - def network(self, name='default'): + def network(self, name="default"): """ Go to network level. @@ -505,7 +650,14 @@ def network(self, name='default'): """ return NetworkFluentAPISelector(selector={}, study=self.study).network(name) - def converter(self, name: str, to_network: str, to_node: str, max: NumericalValueType, cost: NumericalValueType = 0): + def converter( + self, + name: str, + to_network: str, + to_node: str, + max: NumericalValueType, + cost: NumericalValueType = 0, + ): """ Add a converter element. @@ -516,8 +668,9 @@ def converter(self, name: str, to_network: str, to_node: str, max: NumericalValu :param cost: cost for each quantity produce by converter :return: """ - return NetworkFluentAPISelector(selector={}, study=self.study)\ - .converter(name=name, to_network=to_network, to_node=to_node, max=max, cost=cost) + return NetworkFluentAPISelector(selector={}, study=self.study).converter( + name=name, to_network=to_network, to_node=to_node, max=max, cost=cost + ) def to_converter(self, name: str, ratio: NumericalValueType = 1): """ @@ -528,7 +681,12 @@ def to_converter(self, name: str, ratio: NumericalValueType = 1): :return: """ self.study._add_converter(name=name) - self.study._add_converter_src(name=name, network=self.selector['network'], node=self.selector['node'], ratio=ratio) + self.study._add_converter_src( + name=name, + network=self.selector["network"], + node=self.selector["node"], + ratio=ratio, + ) return self def build(self): diff --git a/hadar/optimizer/domain/numeric.py b/hadar/optimizer/domain/numeric.py index da23c5c..18291f9 100644 --- a/hadar/optimizer/domain/numeric.py +++ b/hadar/optimizer/domain/numeric.py @@ -12,13 +12,14 @@ from hadar.optimizer.utils import JSON -T = TypeVar('T') +T = TypeVar("T") class NumericalValue(JSON, ABC, Generic[T]): """ Interface to handle numerical value in study """ + def __init__(self, value: T, horizon: int, nb_scn: int): self.value = value self.horizon = horizon @@ -55,12 +56,17 @@ class ScalarNumericalValue(NumericalValue[float]): """ Implement one scalar numerical value i.e. float or int """ + def __getitem__(self, item) -> float: i, j = item if i >= self.nb_scn: - raise IndexError('There are %d scenario you ask the %dth' % (self.nb_scn, i)) + raise IndexError( + "There are %d scenario you ask the %dth" % (self.nb_scn, i) + ) if j >= self.horizon: - raise IndexError('There are %d time step you ask the %dth' % (self.horizon, j)) + raise IndexError( + "There are %d time step you ask the %dth" % (self.horizon, j) + ) return self.value def __lt__(self, other): @@ -81,6 +87,7 @@ class NumpyNumericalValue(NumericalValue[np.ndarray], ABC): """ Half-implementation with numpy array as numerical value. Implement only compare methods. """ + def __lt__(self, other) -> bool: return np.all(self.value < other) @@ -92,6 +99,7 @@ class MatrixNumericalValue(NumpyNumericalValue): """ Implementation with complex matrix with shape (nb_scn, horizon) """ + def __getitem__(self, item) -> float: i, j = item return self.value[i, j] @@ -108,10 +116,13 @@ class RowNumericValue(NumpyNumericalValue): """ Implementation with one scenario wiht shape (horizon, ). """ + def __getitem__(self, item) -> float: i, j = item if i >= self.nb_scn: - raise IndexError('There are %d scenario you ask the %dth' % (self.nb_scn, i)) + raise IndexError( + "There are %d scenario you ask the %dth" % (self.nb_scn, i) + ) return self.value[j] def flatten(self) -> np.ndarray: @@ -126,10 +137,13 @@ class ColumnNumericValue(NumpyNumericalValue): """ Implementation with one time step by scenario with shape (nb_scn, 1) """ + def __getitem__(self, item) -> float: i, j = item if j >= self.horizon: - raise IndexError('There are %d time step you ask the %dth' % (self.horizon, j)) + raise IndexError( + "There are %d time step you ask the %dth" % (self.horizon, j) + ) return self.value[i, 0] def flatten(self) -> np.ndarray: @@ -141,7 +155,6 @@ def from_json(dict): class NumericalValueFactory: - def __init__(self, horizon: int, nb_scn: int): self.horizon = horizon self.nb_scn = nb_scn @@ -151,17 +164,21 @@ def __eq__(self, other): return False return other.horizon == self.horizon and other.nb_scn == self.nb_scn - def create(self, value: Union[float, List[float], str, np.ndarray, NumericalValue]) -> NumericalValue: + def create( + self, value: Union[float, List[float], str, np.ndarray, NumericalValue] + ) -> NumericalValue: if isinstance(value, NumericalValue): return value # If data come from json serialized dictionary, use 'value' key as input - if isinstance(value, dict) and 'value' in value: - value = value['value'] + if isinstance(value, dict) and "value" in value: + value = value["value"] # If data is just a scalar if type(value) in [float, int, complex]: - return ScalarNumericalValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) + return ScalarNumericalValue( + value=value, horizon=self.horizon, nb_scn=self.nb_scn + ) # If data is list or pandas object convert to numpy array if type(value) in [List, list, pd.DataFrame, pd.Series]: @@ -170,22 +187,30 @@ def create(self, value: Union[float, List[float], str, np.ndarray, NumericalValu if isinstance(value, np.ndarray): # If scenario are not provided copy timeseries for each scenario if value.shape == (self.horizon,): - return RowNumericValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) + return RowNumericValue( + value=value, horizon=self.horizon, nb_scn=self.nb_scn + ) # If horizon are not provide extend each scenario to full horizon if value.shape == (self.nb_scn, 1): - return ColumnNumericValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) + return ColumnNumericValue( + value=value, horizon=self.horizon, nb_scn=self.nb_scn + ) # If perfect size if value.shape == (self.nb_scn, self.horizon): - return MatrixNumericalValue(value=value, horizon=self.horizon, nb_scn=self.nb_scn) + return MatrixNumericalValue( + value=value, horizon=self.horizon, nb_scn=self.nb_scn + ) # If any size pattern matches, raise error on quantity size given horizon_given = value.shape[0] if len(value.shape) == 1 else value.shape[1] sc_given = 1 if len(value.shape) == 1 else value.shape[0] - raise ValueError('Array must be: a number, an array like (horizon, ) or (nb_scn, 1) or (nb_scn, horizon). ' - 'In your case horizon specified is %d and actual is %d. ' - 'And nb_scn specified %d is whereas actual is %d' % - (self.horizon, horizon_given, self.nb_scn, sc_given)) - - raise ValueError('Wrong source data for numerical value') \ No newline at end of file + raise ValueError( + "Array must be: a number, an array like (horizon, ) or (nb_scn, 1) or (nb_scn, horizon). " + "In your case horizon specified is %d and actual is %d. " + "And nb_scn specified %d is whereas actual is %d" + % (self.horizon, horizon_given, self.nb_scn, sc_given) + ) + + raise ValueError("Wrong source data for numerical value") diff --git a/hadar/optimizer/domain/output.py b/hadar/optimizer/domain/output.py index 14a561f..be26482 100644 --- a/hadar/optimizer/domain/output.py +++ b/hadar/optimizer/domain/output.py @@ -11,15 +11,24 @@ from hadar.optimizer.domain.input import InputNode, JSON -__all__ = ['OutputProduction', 'OutputNode', 'OutputStorage', 'OutputLink', 'OutputConsumption', 'OutputNetwork', - 'OutputConverter', 'Result'] +__all__ = [ + "OutputProduction", + "OutputNode", + "OutputStorage", + "OutputLink", + "OutputConsumption", + "OutputNetwork", + "OutputConverter", + "Result", +] class OutputConsumption(JSON): """ Consumption element """ - def __init__(self, quantity: Union[np.ndarray, list], name: str = ''): + + def __init__(self, quantity: Union[np.ndarray, list], name: str = ""): """ Create instance. @@ -29,7 +38,6 @@ def __init__(self, quantity: Union[np.ndarray, list], name: str = ''): self.quantity = np.array(quantity) self.name = name - @staticmethod def from_json(dict, factory=None): return OutputConsumption(**dict) @@ -39,7 +47,8 @@ class OutputProduction(JSON): """ Production element """ - def __init__(self, quantity: Union[np.ndarray, list], name: str = 'in'): + + def __init__(self, quantity: Union[np.ndarray, list], name: str = "in"): """ Create instance. @@ -58,8 +67,14 @@ class OutputStorage(JSON): """ Storage element """ - def __init__(self, name: str, capacity: Union[np.ndarray, list], - flow_in: Union[np.ndarray, list], flow_out: Union[np.ndarray, list]): + + def __init__( + self, + name: str, + capacity: Union[np.ndarray, list], + flow_in: Union[np.ndarray, list], + flow_out: Union[np.ndarray, list], + ): """ Create instance. @@ -82,6 +97,7 @@ class OutputLink(JSON): """ Link element """ + def __init__(self, dest: str, quantity: Union[np.ndarray, list]): """ Create instance. @@ -101,7 +117,13 @@ class OutputConverter(JSON): """ Converter element """ - def __init__(self, name: str, flow_src: Dict[Tuple[str, str], Union[np.ndarray, List]], flow_dest: Union[np.ndarray, List]): + + def __init__( + self, + name: str, + flow_src: Dict[Tuple[str, str], Union[np.ndarray, List]], + flow_dest: Union[np.ndarray, List], + ): """ Create instance. @@ -118,8 +140,8 @@ def to_json(self) -> dict: # flow_src has a tuple of two string as key. These forbidden by JSON. # Therefore when serialized we join these two strings with '::' to create on string as key # Ex: ('elec', 'a') --> 'elec::a' - dict['flow_src'] = {'::'.join(k): v.tolist() for k, v in self.flow_src.items()} - dict['flow_dest'] = self.flow_dest.tolist() + dict["flow_src"] = {"::".join(k): v.tolist() for k, v in self.flow_src.items()} + dict["flow_dest"] = self.flow_dest.tolist() return dict @staticmethod @@ -127,7 +149,9 @@ def from_json(dict: dict, factory=None): # When deserialize, we need to split key string of src_network. # JSON doesn't accept tuple as key, so two string was joined for serialization # Ex: 'elec::a' -> ('elec', 'a') - dict['flow_src'] = {tuple(k.split('::')): v for k, v in dict['flow_src'].items()} + dict["flow_src"] = { + tuple(k.split("::")): v for k, v in dict["flow_src"].items() + } return OutputConverter(**dict) @@ -135,11 +159,14 @@ class OutputNode(JSON): """ Node element """ - def __init__(self, - consumptions: List[OutputConsumption], - productions: List[OutputProduction], - storages: List[OutputStorage], - links: List[OutputLink]): + + def __init__( + self, + consumptions: List[OutputConsumption], + productions: List[OutputProduction], + storages: List[OutputStorage], + links: List[OutputLink], + ): """ Create Node. @@ -163,19 +190,29 @@ def build_like_input(input: InputNode, fill: np.ndarray): :return: OutputNode like InputNode with all quantity at zero """ output = OutputNode(consumptions=[], productions=[], storages=[], links=[]) - output.consumptions = [OutputConsumption(name=i.name, quantity=fill) for i in input.consumptions] - output.productions = [OutputProduction(name=i.name, quantity=fill) for i in input.productions] - output.storages = [OutputStorage(name=i.name, capacity=fill, flow_out=fill, flow_in=fill) - for i in input.storages] + output.consumptions = [ + OutputConsumption(name=i.name, quantity=fill) for i in input.consumptions + ] + output.productions = [ + OutputProduction(name=i.name, quantity=fill) for i in input.productions + ] + output.storages = [ + OutputStorage(name=i.name, capacity=fill, flow_out=fill, flow_in=fill) + for i in input.storages + ] output.links = [OutputLink(dest=i.dest, quantity=fill) for i in input.links] return output @staticmethod def from_json(dict, factory=None): - dict['consumptions'] = [OutputConsumption.from_json(v) for v in dict['consumptions']] - dict['productions'] = [OutputProduction.from_json(v) for v in dict['productions']] - dict['storages'] = [OutputStorage.from_json(v) for v in dict['storages']] - dict['links'] = [OutputLink.from_json(v) for v in dict['links']] + dict["consumptions"] = [ + OutputConsumption.from_json(v) for v in dict["consumptions"] + ] + dict["productions"] = [ + OutputProduction.from_json(v) for v in dict["productions"] + ] + dict["storages"] = [OutputStorage.from_json(v) for v in dict["storages"]] + dict["links"] = [OutputLink.from_json(v) for v in dict["links"]] return OutputNode(**dict) @@ -193,12 +230,18 @@ def __init__(self, nodes: Dict[str, OutputNode]): @staticmethod def from_json(dict, factory=None): - dict['nodes'] = {k: OutputNode.from_json(v) for k, v in dict['nodes'].items()} + dict["nodes"] = {k: OutputNode.from_json(v) for k, v in dict["nodes"].items()} return OutputNetwork(**dict) class Benchmark(JSON): - def __init__(self, modeler: List[int] = None, solver: List[int] = None, mapper: int = 0, total: int = 0): + def __init__( + self, + modeler: List[int] = None, + solver: List[int] = None, + mapper: int = 0, + total: int = 0, + ): self.modeler = modeler or [] self.solver = solver or [] self.mapper = mapper @@ -213,9 +256,13 @@ class Result(JSON): """ Result of study """ - def __init__(self, networks: Dict[str, OutputNetwork], - converters: Dict[str, OutputConverter], - benchmark: Benchmark = None): + + def __init__( + self, + networks: Dict[str, OutputNetwork], + converters: Dict[str, OutputConverter], + benchmark: Benchmark = None, + ): """ Create result :param networks: list of networks present in study @@ -224,9 +271,14 @@ def __init__(self, networks: Dict[str, OutputNetwork], self.converters = converters self.benchmark = benchmark or Benchmark() - @staticmethod def from_json(dict, factory=None): - return Result(networks={k: OutputNetwork.from_json(v) for k, v in dict['networks'].items()}, - converters={k: OutputConverter.from_json(v) for k, v in dict['converters'].items()}, - benchmark=Benchmark.from_json(dict['benchmark'])) + return Result( + networks={ + k: OutputNetwork.from_json(v) for k, v in dict["networks"].items() + }, + converters={ + k: OutputConverter.from_json(v) for k, v in dict["converters"].items() + }, + benchmark=Benchmark.from_json(dict["benchmark"]), + ) diff --git a/hadar/optimizer/lp/__init__.py b/hadar/optimizer/lp/__init__.py index 84711aa..f76a769 100644 --- a/hadar/optimizer/lp/__init__.py +++ b/hadar/optimizer/lp/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/hadar/optimizer/lp/domain.py b/hadar/optimizer/lp/domain.py index 5e695d8..b82e168 100644 --- a/hadar/optimizer/lp/domain.py +++ b/hadar/optimizer/lp/domain.py @@ -22,13 +22,17 @@ def copy(v): return v.solution_value() elif isinstance(v, dict): # Json can't serialize tuple key, therefore join items with :: - return {'::'.join(k) if isinstance(k, tuple) else k: copy(v) for k, v in v.items()} + return { + "::".join(k) if isinstance(k, tuple) else k: copy(v) + for k, v in v.items() + } elif isinstance(v, np.int64): return int(v) elif isinstance(v, np.float64): return float(v) else: return v + return {k: copy(v) for k, v in self.__dict__.items()} @staticmethod @@ -42,7 +46,13 @@ class LPConsumption(JSONLP): Consumption element for linear programming. """ - def __init__(self, quantity: int, variable: Union[Variable, float], cost: float = 0, name: str = ''): + def __init__( + self, + quantity: int, + variable: Union[Variable, float], + cost: float = 0, + name: str = "", + ): """ Instance consumption. @@ -66,7 +76,13 @@ class LPProduction(JSONLP): Production element for linear programming. """ - def __init__(self, quantity: int, variable: Union[Variable, float], cost: float = 0, name: str = 'in'): + def __init__( + self, + quantity: int, + variable: Union[Variable, float], + cost: float = 0, + name: str = "in", + ): """ Instance production. @@ -89,10 +105,20 @@ class LPStorage(JSONLP): """ Storage element """ - def __init__(self, name, capacity: int, var_capacity: Union[Variable, float], - flow_in: float, var_flow_in: Union[Variable, float], - flow_out: float, var_flow_out: Union[Variable, float], - cost: float = 0, init_capacity: int = 0, eff: float = .99): + + def __init__( + self, + name, + capacity: int, + var_capacity: Union[Variable, float], + flow_in: float, + var_flow_in: Union[Variable, float], + flow_out: float, + var_flow_out: Union[Variable, float], + cost: float = 0, + init_capacity: int = 0, + eff: float = 0.99, + ): """ Create storage. @@ -126,7 +152,15 @@ class LPLink(JSONLP): """ Link element for linear programming """ - def __init__(self, src: str, dest: str, quantity: int, variable: Union[Variable, float], cost: float = 0): + + def __init__( + self, + src: str, + dest: str, + quantity: int, + variable: Union[Variable, float], + cost: float = 0, + ): """ Instance Link. @@ -151,11 +185,18 @@ class LPConverter(JSONLP): """ Converter element for linear programming """ - def __init__(self, name: str, src_ratios: Dict[Tuple[str, str], float], - var_flow_src: Dict[Tuple[str, str], Union[Variable, float]], - dest_network: str, dest_node: str, - var_flow_dest: Union[Variable, float], - cost: float, max: float,): + + def __init__( + self, + name: str, + src_ratios: Dict[Tuple[str, str], float], + var_flow_src: Dict[Tuple[str, str], Union[Variable, float]], + dest_network: str, + dest_node: str, + var_flow_dest: Union[Variable, float], + cost: float, + max: float, + ): """ Create converter. @@ -181,8 +222,12 @@ def __init__(self, name: str, src_ratios: Dict[Tuple[str, str], float], @staticmethod def from_json(dict, factory=None): # Json can't serialize tuple as key. tuple is concatained before serialized, we need to extract it now - dict['src_ratios'] = {tuple(k.split('::')): v for k, v in dict['src_ratios'].items()} - dict['var_flow_src'] = {tuple(k.split('::')): v for k, v in dict['var_flow_src'].items()} + dict["src_ratios"] = { + tuple(k.split("::")): v for k, v in dict["src_ratios"].items() + } + dict["var_flow_src"] = { + tuple(k.split("::")): v for k, v in dict["var_flow_src"].items() + } return LPConverter(**dict) @@ -190,8 +235,14 @@ class LPNode(JSON): """ Node element for linear programming """ - def __init__(self, consumptions: List[LPConsumption], productions: List[LPProduction], - storages: List[LPStorage], links: List[LPLink]): + + def __init__( + self, + consumptions: List[LPConsumption], + productions: List[LPProduction], + storages: List[LPStorage], + links: List[LPLink], + ): """ Instance node. @@ -206,10 +257,12 @@ def __init__(self, consumptions: List[LPConsumption], productions: List[LPProduc @staticmethod def from_json(dict, factory=None): - dict['consumptions'] = [LPConsumption.from_json(v) for v in dict['consumptions']] - dict['productions'] = [LPProduction.from_json(v) for v in dict['productions']] - dict['storages'] = [LPStorage.from_json(v) for v in dict['storages']] - dict['links'] = [LPLink.from_json(v) for v in dict['links']] + dict["consumptions"] = [ + LPConsumption.from_json(v) for v in dict["consumptions"] + ] + dict["productions"] = [LPProduction.from_json(v) for v in dict["productions"]] + dict["storages"] = [LPStorage.from_json(v) for v in dict["storages"]] + dict["links"] = [LPLink.from_json(v) for v in dict["links"]] return LPNode(**dict) @@ -228,12 +281,14 @@ def __init__(self, nodes: Dict[str, LPNode] = None): @staticmethod def from_json(dict, factory=None): - dict['nodes'] = {k: LPNode.from_json(v) for k, v in dict['nodes'].items()} + dict["nodes"] = {k: LPNode.from_json(v) for k, v in dict["nodes"].items()} return LPNetwork(**dict) class LPTimeStep(JSON): - def __init__(self, networks: Dict[str, LPNetwork], converters: Dict[str, LPConverter]): + def __init__( + self, networks: Dict[str, LPNetwork], converters: Dict[str, LPConverter] + ): self.networks = networks self.converters = converters @@ -245,5 +300,9 @@ def create_like_study(study: Study): @staticmethod def from_json(dict, factory=None): - return LPTimeStep(networks={k: LPNetwork.from_json(v) for k, v in dict['networks'].items()}, - converters={k: LPConverter.from_json(v) for k, v in dict['converters'].items()}) \ No newline at end of file + return LPTimeStep( + networks={k: LPNetwork.from_json(v) for k, v in dict["networks"].items()}, + converters={ + k: LPConverter.from_json(v) for k, v in dict["converters"].items() + }, + ) diff --git a/hadar/optimizer/lp/mapper.py b/hadar/optimizer/lp/mapper.py index 8c3b242..19db981 100644 --- a/hadar/optimizer/lp/mapper.py +++ b/hadar/optimizer/lp/mapper.py @@ -8,8 +8,20 @@ from ortools.linear_solver.pywraplp import Solver from hadar.optimizer.domain.input import Study, InputNetwork -from hadar.optimizer.lp.domain import LPLink, LPConsumption, LPNode, LPProduction, LPStorage, LPConverter -from hadar.optimizer.domain.output import OutputNode, Result, OutputNetwork, OutputConverter +from hadar.optimizer.lp.domain import ( + LPLink, + LPConsumption, + LPNode, + LPProduction, + LPStorage, + LPConverter, +) +from hadar.optimizer.domain.output import ( + OutputNode, + Result, + OutputNetwork, + OutputConverter, +) class InputMapper: @@ -37,29 +49,85 @@ def get_node_var(self, network: str, node: str, t: int, scn: int) -> LPNode: :param scn: scenario index :return: LPNode according to node name at t in study """ - suffix = 'inside network=%s on node=%s at t=%d for scn=%d' % (network, node, t, scn) + suffix = "inside network=%s on node=%s at t=%d for scn=%d" % ( + network, + node, + t, + scn, + ) in_node = self.study.networks[network].nodes[node] - consumptions = [LPConsumption(name=c.name, cost=c.cost[scn, t], quantity=c.quantity[scn, t], - variable=self.solver.NumVar(0, float(c.quantity[scn, t]), name='lol=%s %s' % (c.name, suffix))) - for c in in_node.consumptions] - - productions = [LPProduction(name=p.name, cost=p.cost[scn, t], quantity=p.quantity[scn, t], - variable=self.solver.NumVar(0, float(p.quantity[scn, t]), 'prod=%s %s' % (p.name, suffix))) - for p in in_node.productions] - - storages = [LPStorage(name=s.name, flow_in=s.flow_in[scn, t], flow_out=s.flow_out[scn, t], eff=s.eff[scn, t], - capacity=s.capacity[scn, t], init_capacity=s.init_capacity, cost=s.cost[scn, t], - var_capacity=self.solver.NumVar(0, float(s.capacity[scn, t]), 'storage_capacity=%s %s' % (s.name, suffix)), - var_flow_in=self.solver.NumVar(0, float(s.flow_in[scn, t]), 'storage_flow_in=%s %s' % (s.name, suffix)), - var_flow_out=self.solver.NumVar(0, float(s.flow_out[scn, t]), 'storage_flow_out=%s %s' % (s.name, suffix))) - for s in in_node.storages] - - links = [LPLink(dest=l.dest, cost=l.cost[scn, t], src=node, quantity=l.quantity[scn, t], - variable=self.solver.NumVar(0, float(l.quantity[scn, t]), 'link=%s %s' % (l.dest, suffix))) - for l in in_node.links] - - return LPNode(consumptions=consumptions, productions=productions, links=links, storages=storages) + consumptions = [ + LPConsumption( + name=c.name, + cost=c.cost[scn, t], + quantity=c.quantity[scn, t], + variable=self.solver.NumVar( + 0, float(c.quantity[scn, t]), name="lol=%s %s" % (c.name, suffix) + ), + ) + for c in in_node.consumptions + ] + + productions = [ + LPProduction( + name=p.name, + cost=p.cost[scn, t], + quantity=p.quantity[scn, t], + variable=self.solver.NumVar( + 0, float(p.quantity[scn, t]), "prod=%s %s" % (p.name, suffix) + ), + ) + for p in in_node.productions + ] + + storages = [ + LPStorage( + name=s.name, + flow_in=s.flow_in[scn, t], + flow_out=s.flow_out[scn, t], + eff=s.eff[scn, t], + capacity=s.capacity[scn, t], + init_capacity=s.init_capacity, + cost=s.cost[scn, t], + var_capacity=self.solver.NumVar( + 0, + float(s.capacity[scn, t]), + "storage_capacity=%s %s" % (s.name, suffix), + ), + var_flow_in=self.solver.NumVar( + 0, + float(s.flow_in[scn, t]), + "storage_flow_in=%s %s" % (s.name, suffix), + ), + var_flow_out=self.solver.NumVar( + 0, + float(s.flow_out[scn, t]), + "storage_flow_out=%s %s" % (s.name, suffix), + ), + ) + for s in in_node.storages + ] + + links = [ + LPLink( + dest=l.dest, + cost=l.cost[scn, t], + src=node, + quantity=l.quantity[scn, t], + variable=self.solver.NumVar( + 0, float(l.quantity[scn, t]), "link=%s %s" % (l.dest, suffix) + ), + ) + for l in in_node.links + ] + + return LPNode( + consumptions=consumptions, + productions=productions, + links=links, + storages=storages, + ) def get_conv_var(self, name: str, t: int, scn: int) -> LPConverter: """ @@ -70,21 +138,36 @@ def get_conv_var(self, name: str, t: int, scn: int) -> LPConverter: :param scn: scenario index :return: LPConverter """ - suffix = 'at t=%d for scn=%d' % (t, scn) + suffix = "at t=%d for scn=%d" % (t, scn) v = self.study.converters[name] src_ratios = {k: v[scn, t] for k, v in v.src_ratios.items()} - return LPConverter(name=v.name, src_ratios=src_ratios, dest_network=v.dest_network, dest_node=v.dest_node, - cost=v.cost[scn, t], max=v.max[scn, t], - var_flow_src={src: self.solver.NumVar(0, float(v.max[scn, t] / r), 'flow_src %s %s %s' % (v.name, ':'.join(src), suffix)) - for src, r in src_ratios.items()}, - var_flow_dest=self.solver.NumVar(0, float(v.max[scn, t]), 'flow_dest %s %s' % (v.name, suffix))) + return LPConverter( + name=v.name, + src_ratios=src_ratios, + dest_network=v.dest_network, + dest_node=v.dest_node, + cost=v.cost[scn, t], + max=v.max[scn, t], + var_flow_src={ + src: self.solver.NumVar( + 0, + float(v.max[scn, t] / r), + "flow_src %s %s %s" % (v.name, ":".join(src), suffix), + ) + for src, r in src_ratios.items() + }, + var_flow_dest=self.solver.NumVar( + 0, float(v.max[scn, t]), "flow_dest %s %s" % (v.name, suffix) + ), + ) class OutputMapper: """ Output mapper from specific linear programming domain to global domain. """ + def __init__(self, study: Study): """ Instantiate mapper. @@ -93,12 +176,25 @@ def __init__(self, study: Study): :param study: input study to reproduce structure """ zeros = np.zeros((study.nb_scn, study.horizon)) - def build_nodes(network: InputNetwork): - return {name: OutputNode.build_like_input(input, fill=zeros) for name, input in network.nodes.items()} - self.networks = {name: OutputNetwork(nodes=build_nodes(network)) for name, network in study.networks.items()} - self.converters = {name: OutputConverter(name=name, flow_src={src: zeros for src in conv.src_ratios}, flow_dest=zeros) - for name, conv in study.converters.items()} + def build_nodes(network: InputNetwork): + return { + name: OutputNode.build_like_input(input, fill=zeros) + for name, input in network.nodes.items() + } + + self.networks = { + name: OutputNetwork(nodes=build_nodes(network)) + for name, network in study.networks.items() + } + self.converters = { + name: OutputConverter( + name=name, + flow_src={src: zeros for src in conv.src_ratios}, + flow_dest=zeros, + ) + for name, conv in study.converters.items() + } def set_node_var(self, network: str, node: str, t: int, scn: int, vars: LPNode): """ @@ -113,7 +209,9 @@ def set_node_var(self, network: str, node: str, t: int, scn: int, vars: LPNode): """ out_node = self.networks[network].nodes[node] for i in range(len(vars.consumptions)): - out_node.consumptions[i].quantity[scn, t] = vars.consumptions[i].quantity - vars.consumptions[i].variable + out_node.consumptions[i].quantity[scn, t] = ( + vars.consumptions[i].quantity - vars.consumptions[i].variable + ) for i in range(len(vars.productions)): out_node.productions[i].quantity[scn, t] = vars.productions[i].variable @@ -124,7 +222,9 @@ def set_node_var(self, network: str, node: str, t: int, scn: int, vars: LPNode): out_node.storages[i].flow_out[scn, t] = vars.storages[i].var_flow_out for i in range(len(vars.links)): - self.networks[network].nodes[node].links[i].quantity[scn, t] = vars.links[i].variable + self.networks[network].nodes[node].links[i].quantity[scn, t] = vars.links[ + i + ].variable def set_converter_var(self, name: str, t: int, scn: int, vars: LPConverter): for src, var in vars.var_flow_src.items(): diff --git a/hadar/optimizer/lp/optimizer.py b/hadar/optimizer/lp/optimizer.py index 2570201..e2eb4bd 100644 --- a/hadar/optimizer/lp/optimizer.py +++ b/hadar/optimizer/lp/optimizer.py @@ -14,7 +14,15 @@ from ortools.linear_solver.pywraplp import Solver, Constraint from hadar.optimizer.domain.input import Study -from hadar.optimizer.lp.domain import LPNode, LPProduction, LPConsumption, LPLink, LPStorage, LPTimeStep, LPConverter +from hadar.optimizer.lp.domain import ( + LPNode, + LPProduction, + LPConsumption, + LPLink, + LPStorage, + LPTimeStep, + LPConverter, +) from hadar.optimizer.lp.mapper import InputMapper, OutputMapper from hadar.optimizer.domain.output import Result, Benchmark from hadar.optimizer.utils import JSON @@ -35,7 +43,7 @@ def __init__(self, solver: Solver): """ self.objective = solver.Objective() self.objective.SetMinimization() - self.logger = logging.getLogger(__name__ + '.' + self.__class__.__name__) + self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__) def add_node(self, node: LPNode): """ @@ -58,7 +66,7 @@ def _add_consumption(self, consumptions: List[LPConsumption]): """ for cons in consumptions: self.objective.SetCoefficient(cons.variable, cons.cost) - self.logger.debug('Add consumption %s into objective', cons.name) + self.logger.debug("Add consumption %s into objective", cons.name) def _add_productions(self, prods: List[LPProduction]): """ @@ -69,7 +77,7 @@ def _add_productions(self, prods: List[LPProduction]): """ for prod in prods: self.objective.SetCoefficient(prod.variable, prod.cost) - self.logger.debug('Add production %s into objective', prod.name) + self.logger.debug("Add production %s into objective", prod.name) def _add_storages(self, stors: List[LPStorage]): """ @@ -79,7 +87,7 @@ def _add_storages(self, stors: List[LPStorage]): """ for stor in stors: self.objective.SetCoefficient(stor.var_capacity, stor.cost) - self.logger.debug('Add storage %s into objective', stor.name) + self.logger.debug("Add storage %s into objective", stor.name) def _add_links(self, links: List[LPLink]): """ @@ -90,7 +98,7 @@ def _add_links(self, links: List[LPLink]): """ for link in links: self.objective.SetCoefficient(link.variable, link.cost) - self.logger.debug('Add link %s->%s to objective', link.src, link.dest) + self.logger.debug("Add link %s->%s to objective", link.src, link.dest) def add_converter(self, conv: LPConverter): """ @@ -100,7 +108,7 @@ def add_converter(self, conv: LPConverter): :return: """ self.objective.SetCoefficient(conv.var_flow_dest, conv.cost) - self.logger.debug('Add converter %s to objective' % conv.name) + self.logger.debug("Add converter %s to objective" % conv.name) def build(self): pass # Currently nothing are need at the end. But we keep builder pattern syntax @@ -110,6 +118,7 @@ class AdequacyBuilder: """ Build adequacy flow constraint. """ + def __init__(self, solver: Solver): """ Initiate. @@ -120,7 +129,7 @@ def __init__(self, solver: Solver): self.constraints = dict() self.importations = dict() self.solver = solver - self.logger = logging.getLogger(__name__ + '.' + self.__class__.__name__) + self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__) def add_node(self, name_network: str, name_node: str, node: LPNode, t: int): """ @@ -133,14 +142,22 @@ def add_node(self, name_network: str, name_node: str, node: LPNode, t: int): """ # Set forced consumption load = sum([c.quantity for c in node.consumptions]) * 1.0 - self.constraints[(t, name_network, name_node)] = self.solver.Constraint(load, load) + self.constraints[(t, name_network, name_node)] = self.solver.Constraint( + load, load + ) self._add_consumptions(name_network, name_node, t, node.consumptions) self._add_productions(name_network, name_node, t, node.productions) self._add_storages(name_network, name_node, t, node.storages) self._add_links(name_network, name_node, t, node.links) - def _add_consumptions(self, name_network: str, name_node: str, t: int, consumptions: List[LPConsumption]): + def _add_consumptions( + self, + name_network: str, + name_node: str, + t: int, + consumptions: List[LPConsumption], + ): """ Add consumption flow. That mean loss of consumption is set a production to match equation in case there are not enough production. @@ -152,10 +169,19 @@ def _add_consumptions(self, name_network: str, name_node: str, t: int, consumpti :return: """ for cons in consumptions: - self.constraints[(t, name_network, name_node)].SetCoefficient(cons.variable, 1) - self.logger.debug('Add lol %s for %s inside %s into adequacy constraint', cons.name, name_node, name_network) - - def _add_productions(self, name_network: str, name_node: str, t: int, productions: List[LPProduction]): + self.constraints[(t, name_network, name_node)].SetCoefficient( + cons.variable, 1 + ) + self.logger.debug( + "Add lol %s for %s inside %s into adequacy constraint", + cons.name, + name_node, + name_network, + ) + + def _add_productions( + self, name_network: str, name_node: str, t: int, productions: List[LPProduction] + ): """ Add production flow. That mean production use is like a production. @@ -166,10 +192,19 @@ def _add_productions(self, name_network: str, name_node: str, t: int, production :return: """ for prod in productions: - self.constraints[(t, name_network, name_node)].SetCoefficient(prod.variable, 1) - self.logger.debug('Add prod %s for %s inside %s into adequacy constraint', prod.name, name_node, name_network) - - def _add_storages(self, name_network: str, name_node: str, t: int, storages: List[LPStorage]): + self.constraints[(t, name_network, name_node)].SetCoefficient( + prod.variable, 1 + ) + self.logger.debug( + "Add prod %s for %s inside %s into adequacy constraint", + prod.name, + name_node, + name_network, + ) + + def _add_storages( + self, name_network: str, name_node: str, t: int, storages: List[LPStorage] + ): """ Add storage flow. Flow in is like a consumption. Flow out is like a production. @@ -180,11 +215,22 @@ def _add_storages(self, name_network: str, name_node: str, t: int, storages: Lis :return: """ for stor in storages: - self.constraints[(t, name_network, name_node)].SetCoefficient(stor.var_flow_in, -1) - self.constraints[(t, name_network, name_node)].SetCoefficient(stor.var_flow_out, 1) - self.logger.debug('Add storage %s for %s inside %s into adequacy constraint', stor.name, name_node, name_network) - - def _add_links(self, name_network: str, name_node: str, t: int, links: List[LPLink]): + self.constraints[(t, name_network, name_node)].SetCoefficient( + stor.var_flow_in, -1 + ) + self.constraints[(t, name_network, name_node)].SetCoefficient( + stor.var_flow_out, 1 + ) + self.logger.debug( + "Add storage %s for %s inside %s into adequacy constraint", + stor.name, + name_node, + name_network, + ) + + def _add_links( + self, name_network: str, name_node: str, t: int, links: List[LPLink] + ): """ Add links. That mean the link export is like a consumption. After all node added. The same export, become also an import for destination node. @@ -197,9 +243,18 @@ def _add_links(self, name_network: str, name_node: str, t: int, links: List[LPLi :return: """ for link in links: - self.constraints[(t, name_network, link.src)].SetCoefficient(link.variable, -1) # Export from src - self.importations[(t, name_network, link.src, link.dest)] = link.variable # Import to dest - self.logger.debug('Add link %s for %s inside %s into adequacy constraint', link.dest, name_node, name_network) + self.constraints[(t, name_network, link.src)].SetCoefficient( + link.variable, -1 + ) # Export from src + self.importations[ + (t, name_network, link.src, link.dest) + ] = link.variable # Import to dest + self.logger.debug( + "Add link %s for %s inside %s into adequacy constraint", + link.dest, + name_node, + name_network, + ) def add_converter(self, conv: LPConverter, t: int): """ @@ -209,10 +264,12 @@ def add_converter(self, conv: LPConverter, t: int): :param t: time index to use :return: """ - self.constraints[(t, conv.dest_network, conv.dest_node)].SetCoefficient(conv.var_flow_dest, 1) + self.constraints[(t, conv.dest_network, conv.dest_node)].SetCoefficient( + conv.var_flow_dest, 1 + ) for (network, node), var in conv.var_flow_src.items(): self.constraints[(t, network, node)].SetCoefficient(var, -1) - self.logger.debug('Add converter %s' % conv.name) + self.logger.debug("Add converter %s" % conv.name) def build(self): """ @@ -233,9 +290,11 @@ class StorageBuilder: def __init__(self, solver: Solver): self.capacities = dict() self.solver = solver - self.logger = logging.getLogger(__name__ + '.' + self.__class__.__name__) + self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__) - def add_node(self, name_network: str, name_node: str, node: LPNode, t: int) -> Constraint: + def add_node( + self, name_network: str, name_node: str, node: LPNode, t: int + ) -> Constraint: for stor in node.storages: self.capacities[(t, name_network, name_node, stor.name)] = stor.var_capacity if t == 0: @@ -248,7 +307,9 @@ def add_node(self, name_network: str, name_node: str, node: LPNode, t: int) -> C const = self.solver.Constraint(0, 0) const.SetCoefficient(stor.var_flow_in, -stor.eff) const.SetCoefficient(stor.var_flow_out, 1) - const.SetCoefficient(self.capacities[(t-1, name_network, name_node, stor.name)], -1) + const.SetCoefficient( + self.capacities[(t - 1, name_network, name_node, stor.name)], -1 + ) const.SetCoefficient(stor.var_capacity, 1) return const @@ -260,13 +321,18 @@ class ConverterMixBuilder: """ Build equation to determine ratio mix between sources converter. """ + def __init__(self, solver: Solver): self.solver = solver - self.logger = logging.getLogger(__name__ + '.' + self.__class__.__name__) + self.logger = logging.getLogger(__name__ + "." + self.__class__.__name__) def add_converter(self, conv: LPConverter): - return [ConverterMixBuilder._build_constraint(self.solver, r, conv.var_flow_dest, conv.var_flow_src[src]) - for src, r in conv.src_ratios.items()] + return [ + ConverterMixBuilder._build_constraint( + self.solver, r, conv.var_flow_dest, conv.var_flow_src[src] + ) + for src, r in conv.src_ratios.items() + ] @staticmethod def _build_constraint(solver, r, var_dest, var_src): @@ -290,7 +356,7 @@ def _solve_batch(params) -> bytes: if len(params) == 2: # Runtime study, i_scn = params - solver = Solver('simple_lp_program', Solver.GLOP_LINEAR_PROGRAMMING) + solver = Solver("simple_lp_program", Solver.GLOP_LINEAR_PROGRAMMING) objective = ObjectiveBuilder(solver=solver) adequacy = AdequacyBuilder(solver=solver) @@ -309,10 +375,16 @@ def _solve_batch(params) -> bytes: # Build node constraints for name_network, network in study.networks.items(): for name_node, node in network.nodes.items(): - node = in_mapper.get_node_var(network=name_network, node=name_node, t=t, scn=i_scn) + node = in_mapper.get_node_var( + network=name_network, node=name_node, t=t, scn=i_scn + ) variables[t].networks[name_network].nodes[name_node] = node - adequacy.add_node(name_network=name_network, name_node=name_node, node=node, t=t) - storage.add_node(name_network=name_network, name_node=name_node, node=node, t=t) + adequacy.add_node( + name_network=name_network, name_node=name_node, node=node, t=t + ) + storage.add_node( + name_network=name_network, name_node=name_node, node=node, t=t + ) objective.add_node(node=node) # Build converter constraints @@ -324,24 +396,29 @@ def _solve_batch(params) -> bytes: objective.add_converter(conv=conv) objective.build() - adequacy .build() + adequacy.build() storage.build() mix.build() problem_build = time.time() - logger.info('Problem build. Start solver') + logger.info("Problem build. Start solver") solver.EnableOutput() solver.Solve() problem_solved = time.time() - logger.info('Solver finish cost=%d', solver.Objective().Value()) - logger.debug(solver.ExportModelAsLpFormat(False).replace('\\', '').replace(',_', ',')) + logger.info("Solver finish cost=%d", solver.Objective().Value()) + logger.debug( + solver.ExportModelAsLpFormat(False).replace("\\", "").replace(",_", ",") + ) # When multiprocessing handle response and serialize it with pickle, # it's occur that ortools variables seem already erased. # To fix this situation, serialization is handle inside 'job scope' - return msgpack.packb((JSON.convert(variables), problem_build - start, problem_solved - start), use_bin_type=True) + return msgpack.packb( + (JSON.convert(variables), problem_build - start, problem_solved - start), + use_bin_type=True, + ) def _wrap_profiler(param): @@ -352,7 +429,9 @@ def _wrap_profiler(param): :param param: :return: """ - return cProfile.runctx('_solve_batch(param)', globals(), locals(), 'prof%d.prof' % param[1]) + return cProfile.runctx( + "_solve_batch(param)", globals(), locals(), "prof%d.prof" % param[1] + ) def solve_lp(study: Study, out_mapper=None) -> Result: @@ -369,11 +448,15 @@ def solve_lp(study: Study, out_mapper=None) -> Result: out_mapper = out_mapper or OutputMapper(study) pool = multiprocessing.Pool() - serialized_out = pool.map(_solve_batch, ((study, i_scn) for i_scn in range(study.nb_scn))) + serialized_out = pool.map( + _solve_batch, ((study, i_scn) for i_scn in range(study.nb_scn)) + ) compute_finished = time.time() for scn in range(0, study.nb_scn): - variables, modeler, solver = msgpack.unpackb(serialized_out[scn], use_list=False, raw=False) + variables, modeler, solver = msgpack.unpackb( + serialized_out[scn], use_list=False, raw=False + ) benchmark.modeler.append(modeler) benchmark.solver.append(solver) @@ -382,11 +465,21 @@ def solve_lp(study: Study, out_mapper=None) -> Result: # Set node elements for name_network, network in study.networks.items(): for name_node in network.nodes.keys(): - out_mapper.set_node_var(network=name_network, node=name_node, t=t, scn=scn, - vars=variables[t].networks[name_network].nodes[name_node]) + out_mapper.set_node_var( + network=name_network, + node=name_node, + t=t, + scn=scn, + vars=variables[t].networks[name_network].nodes[name_node], + ) # Set converters for name_conv in study.converters: - out_mapper.set_converter_var(name=name_conv, t=t, scn=scn, vars=variables[t].converters[name_conv]) + out_mapper.set_converter_var( + name=name_conv, + t=t, + scn=scn, + vars=variables[t].converters[name_conv], + ) benchmark.total = time.time() - start benchmark.mapper = time.time() - compute_finished diff --git a/hadar/optimizer/optimizer.py b/hadar/optimizer/optimizer.py index 0fa5b30..c044c0f 100644 --- a/hadar/optimizer/optimizer.py +++ b/hadar/optimizer/optimizer.py @@ -12,11 +12,12 @@ from hadar.optimizer.domain.output import Result from hadar.optimizer.remote.optimizer import solve_remote -__all__ = ['LPOptimizer', 'RemoteOptimizer'] +__all__ = ["LPOptimizer", "RemoteOptimizer"] class Optimizer(ABC): """Optimizer interface to implement""" + @abstractmethod def solve(self, study: Study) -> Result: pass @@ -26,6 +27,7 @@ class LPOptimizer(Optimizer): """ Basic Optimizer works with linear programming. """ + def solve(self, study: Study) -> Result: """ Solve adequacy study. @@ -40,7 +42,8 @@ class RemoteOptimizer(Optimizer): """ Use a remote optimizer to compute on cloud. """ - def __init__(self, url: str, token: str = ''): + + def __init__(self, url: str, token: str = ""): """ Server optimizer parameter. @@ -58,4 +61,3 @@ def solve(self, study: Study) -> Result: :return: study's result """ return solve_remote(study, url=self.url, token=self.token) - diff --git a/hadar/optimizer/remote/__init__.py b/hadar/optimizer/remote/__init__.py index 84711aa..f76a769 100644 --- a/hadar/optimizer/remote/__init__.py +++ b/hadar/optimizer/remote/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/hadar/optimizer/remote/optimizer.py b/hadar/optimizer/remote/optimizer.py index 738458d..9ba1a49 100644 --- a/hadar/optimizer/remote/optimizer.py +++ b/hadar/optimizer/remote/optimizer.py @@ -32,7 +32,7 @@ def check_code(code): raise IOError("Error has occurred on remote server") -def solve_remote(study: Study, url: str, token: str = 'none') -> Result: +def solve_remote(study: Study, url: str, token: str = "none") -> Result: """ Send study to remote server. @@ -42,35 +42,39 @@ def solve_remote(study: Study, url: str, token: str = 'none') -> Result: :return: result received from server """ # Send study - resp = requests.post(url='%s/api/v1/study' % url, json=study.to_json(), params={'token': token}) + resp = requests.post( + url="%s/api/v1/study" % url, json=study.to_json(), params={"token": token} + ) check_code(resp.status_code) # Deserialize resp = resp.json() - id = resp['job'] + id = resp["job"] Bar.check_tty = Spinner.check_tty = False Bar.file = Spinner.file = sys.stdout - bar = Bar('QUEUED', max=resp['progress']) + bar = Bar("QUEUED", max=resp["progress"]) spinner = None - while resp['status'] in ['QUEUED', 'COMPUTING']: - resp = requests.get(url='%s/api/v1/result/%s' % (url, id), params={'token': token}) + while resp["status"] in ["QUEUED", "COMPUTING"]: + resp = requests.get( + url="%s/api/v1/result/%s" % (url, id), params={"token": token} + ) check_code(resp.status_code) resp = resp.json() - if resp['status'] == 'QUEUED': - bar.goto(resp['progress']) + if resp["status"] == "QUEUED": + bar.goto(resp["progress"]) - if resp['status'] == 'COMPUTING': + if resp["status"] == "COMPUTING": if spinner is None: bar.finish() - spinner = Spinner('COMPUTING ') + spinner = Spinner("COMPUTING ") spinner.next() sleep(0.5) - if resp['status'] == 'ERROR': - raise ServerError(resp['message']) + if resp["status"] == "ERROR": + raise ServerError(resp["message"]) - return Result.from_json(resp['result']) + return Result.from_json(resp["result"]) diff --git a/hadar/optimizer/utils.py b/hadar/optimizer/utils.py index 83bc122..eed3aca 100644 --- a/hadar/optimizer/utils.py +++ b/hadar/optimizer/utils.py @@ -12,6 +12,7 @@ class DTO: """ Implement basic method for DTO objects """ + def __hash__(self): return hash(tuple(sorted(self.__dict__.items()))) @@ -19,7 +20,15 @@ def __eq__(self, other): return isinstance(other, type(self)) and self.__dict__ == other.__dict__ def __str__(self): - return "{}({})".format(type(self).__name__, ", ".join(["{}={}".format(k, str(self.__dict__[k])) for k in sorted(self.__dict__)])) + return "{}({})".format( + type(self).__name__, + ", ".join( + [ + "{}={}".format(k, str(self.__dict__[k])) + for k in sorted(self.__dict__) + ] + ), + ) def __repr__(self): return self.__str__() @@ -52,4 +61,4 @@ def to_json(self): @staticmethod @abstractmethod def from_json(dict, factory=None): - pass \ No newline at end of file + pass diff --git a/hadar/viewer/__init__.py b/hadar/viewer/__init__.py index 84711aa..f76a769 100644 --- a/hadar/viewer/__init__.py +++ b/hadar/viewer/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/hadar/viewer/abc.py b/hadar/viewer/abc.py index 0dc43a4..5154d47 100644 --- a/hadar/viewer/abc.py +++ b/hadar/viewer/abc.py @@ -18,6 +18,7 @@ class ABCElementPlotting(ABC): """ Abstract interface to implement to plot graphics """ + @abstractmethod def timeline(self, df: pd.DataFrame, title: str): """ @@ -64,7 +65,12 @@ def candles(self, open: np.ndarray, close: np.ndarray, title: str): pass @abstractmethod - def stack(self, areas: List[Tuple[str, np.ndarray]], lines: List[Tuple[str, np.ndarray]], title: str): + def stack( + self, + areas: List[Tuple[str, np.ndarray]], + lines: List[Tuple[str, np.ndarray]], + title: str, + ): """ Plot stack. @@ -108,15 +114,23 @@ def __init__(self, plotting: ABCElementPlotting, agg: ResultAnalyzer): @staticmethod def not_both(t: int, scn: int): if t is not None and scn is not None: - raise ValueError('you have to specify time or scenario index but not both') + raise ValueError("you have to specify time or scenario index but not both") class ConsumptionFluentAPISelector(FluentAPISelector): """ Consumption level of fluent api. """ - def __init__(self, plotting: ABCElementPlotting, agg: ResultAnalyzer, - network: str, name: str, node: str, kind: str): + + def __init__( + self, + plotting: ABCElementPlotting, + agg: ResultAnalyzer, + network: str, + name: str, + node: str, + kind: str, + ): FluentAPISelector.__init__(self, plotting, agg) self.name = name self.node = node @@ -128,8 +142,14 @@ def timeline(self): Plot timeline graphics. :return: """ - cons = self.agg.network(self.network).node(self.node).consumption(self.name).scn().time()[self.kind] - title = 'Consumptions %s for %s on node %s' % (self.kind, self.name, self.node) + cons = ( + self.agg.network(self.network) + .node(self.node) + .consumption(self.name) + .scn() + .time()[self.kind] + ) + title = "Consumptions %s for %s on node %s" % (self.kind, self.name, self.node) return self.plotting.timeline(cons, title) def monotone(self, t: int = None, scn: int = None): @@ -143,11 +163,33 @@ def monotone(self, t: int = None, scn: int = None): FluentAPISelector.not_both(t, scn) if t is not None: - y = self.agg.network(self.network).node(self.node).consumption(self.name).time(t).scn()[self.kind].values - title = 'Monotone consumption of %s on node %s at t=%0d' % (self.name, self.node, t) + y = ( + self.agg.network(self.network) + .node(self.node) + .consumption(self.name) + .time(t) + .scn()[self.kind] + .values + ) + title = "Monotone consumption of %s on node %s at t=%0d" % ( + self.name, + self.node, + t, + ) elif scn is not None: - y = self.agg.network(self.network).node(self.node).consumption(self.name).scn(scn).time()[self.kind].values - title = 'Monotone consumption of %s on node %s at scn=%0d' % (self.name, self.node, scn) + y = ( + self.agg.network(self.network) + .node(self.node) + .consumption(self.name) + .scn(scn) + .time()[self.kind] + .values + ) + title = "Monotone consumption of %s on node %s at scn=%0d" % ( + self.name, + self.node, + scn, + ) return self.plotting.monotone(y, title) @@ -162,13 +204,35 @@ def gaussian(self, t: int = None, scn: int = None): FluentAPISelector.not_both(t, scn) if t is None: - cons = self.agg.network(self.network).node(self.node).consumption(self.name).scn(scn).time()[self.kind].values + cons = ( + self.agg.network(self.network) + .node(self.node) + .consumption(self.name) + .scn(scn) + .time()[self.kind] + .values + ) rac = self.agg.get_rac(network=self.network)[scn, :] - title = 'Gaussian consumption of %s on node %s at scn=%0d' % (self.name, self.node, scn) + title = "Gaussian consumption of %s on node %s at scn=%0d" % ( + self.name, + self.node, + scn, + ) elif scn is None: - cons = self.agg.network(self.network).node(self.node).consumption(self.name).time(t).scn()[self.kind].values + cons = ( + self.agg.network(self.network) + .node(self.node) + .consumption(self.name) + .time(t) + .scn()[self.kind] + .values + ) rac = self.agg.get_rac(network=self.network)[:, t] - title = 'Gaussian consumption of %s on node %s at t=%0d' % (self.name, self.node, t) + title = "Gaussian consumption of %s on node %s at t=%0d" % ( + self.name, + self.node, + t, + ) return self.plotting.gaussian(rac=rac, qt=cons, title=title) @@ -177,8 +241,16 @@ class ProductionFluentAPISelector(FluentAPISelector): """ Production level of fluent api """ - def __init__(self, plotting: ABCElementPlotting, agg: ResultAnalyzer, - network: str, name: str, node: str, kind: str): + + def __init__( + self, + plotting: ABCElementPlotting, + agg: ResultAnalyzer, + network: str, + name: str, + node: str, + kind: str, + ): FluentAPISelector.__init__(self, plotting, agg) self.name = name self.node = node @@ -190,8 +262,14 @@ def timeline(self): Plot timeline graphics. :return: """ - prod = self.agg.network(self.network).node(self.node).production(self.name).scn().time()[self.kind] - title = 'Production %s for %s on node %s' % (self.kind, self.name, self.node) + prod = ( + self.agg.network(self.network) + .node(self.node) + .production(self.name) + .scn() + .time()[self.kind] + ) + title = "Production %s for %s on node %s" % (self.kind, self.name, self.node) return self.plotting.timeline(prod, title) def monotone(self, t: int = None, scn: int = None): @@ -205,11 +283,33 @@ def monotone(self, t: int = None, scn: int = None): FluentAPISelector.not_both(t, scn) if t is not None: - y = self.agg.network(self.network).node(self.node).production(self.name).time(t).scn()[self.kind].values - title = 'Monotone production of %s on node %s at t=%0d' % (self.name, self.node, t) + y = ( + self.agg.network(self.network) + .node(self.node) + .production(self.name) + .time(t) + .scn()[self.kind] + .values + ) + title = "Monotone production of %s on node %s at t=%0d" % ( + self.name, + self.node, + t, + ) elif scn is not None: - y = self.agg.network(self.network).node(self.node).production(self.name).scn(scn).time()[self.kind].values - title = 'Monotone production of %s on node %s at scn=%0d' % (self.name, self.node, scn) + y = ( + self.agg.network(self.network) + .node(self.node) + .production(self.name) + .scn(scn) + .time()[self.kind] + .values + ) + title = "Monotone production of %s on node %s at scn=%0d" % ( + self.name, + self.node, + scn, + ) return self.plotting.monotone(y, title) @@ -224,13 +324,35 @@ def gaussian(self, t: int = None, scn: int = None): FluentAPISelector.not_both(t, scn) if t is None: - prod = self.agg.network(self.network).node(self.node).production(self.name).scn(scn).time()[self.kind].values + prod = ( + self.agg.network(self.network) + .node(self.node) + .production(self.name) + .scn(scn) + .time()[self.kind] + .values + ) rac = self.agg.get_rac(network=self.network)[scn, :] - title = 'Gaussian production of %s on node %s at scn=%0d' % (self.name, self.node, scn) + title = "Gaussian production of %s on node %s at scn=%0d" % ( + self.name, + self.node, + scn, + ) elif scn is None: - prod = self.agg.network(self.network).node(self.node).production(self.name).time(t).scn()[self.kind].values + prod = ( + self.agg.network(self.network) + .node(self.node) + .production(self.name) + .time(t) + .scn()[self.kind] + .values + ) rac = self.agg.get_rac(network=self.network)[:, t] - title = 'Gaussian production of %s on node %s at t=%0d' % (self.name, self.node, t) + title = "Gaussian production of %s on node %s at t=%0d" % ( + self.name, + self.node, + t, + ) return self.plotting.gaussian(rac=rac, qt=prod, title=title) @@ -239,23 +361,42 @@ class StorageFluentAPISelector(FluentAPISelector): """ Storage level of fluent API """ - def __init__(self, plotting: ABCElementPlotting, agg: ResultAnalyzer, - network: str, node: str, name: str): + + def __init__( + self, + plotting: ABCElementPlotting, + agg: ResultAnalyzer, + network: str, + node: str, + name: str, + ): FluentAPISelector.__init__(self, plotting, agg) self.network = network self.node = node self.name = name def candles(self, scn: int = 0): - df = self.agg.network(self.network).node(self.node).storage(self.name).scn(scn).time() + df = ( + self.agg.network(self.network) + .node(self.node) + .storage(self.name) + .scn(scn) + .time() + ) df.sort_index(ascending=True, inplace=True) - open = np.append(df['init_capacity'][0], (df['flow_in'] * df['eff'] - df['flow_out']).values) + open = np.append( + df["init_capacity"][0], (df["flow_in"] * df["eff"] - df["flow_out"]).values + ) open = open.cumsum() close = open[1:] open = open[:-1] - title = 'Stockage capacity of %s on node %s for scn=%d' % (self.name, self.node, scn) + title = "Stockage capacity of %s on node %s for scn=%d" % ( + self.name, + self.node, + scn, + ) return self.plotting.candles(open=open, close=close, title=title) def monotone(self, t: int = None, scn: int = None): @@ -269,15 +410,35 @@ def monotone(self, t: int = None, scn: int = None): FluentAPISelector.not_both(t, scn) if t is not None: - df = self.agg.network(self.network).node(self.node).storage(self.name).time(t).scn() + df = ( + self.agg.network(self.network) + .node(self.node) + .storage(self.name) + .time(t) + .scn() + ) df.sort_index(ascending=True, inplace=True) - y = (df['flow_in'] - df['flow_out']).values - title = 'Monotone storage of %s on node %s at t=%0d' % (self.name, self.node, t) + y = (df["flow_in"] - df["flow_out"]).values + title = "Monotone storage of %s on node %s at t=%0d" % ( + self.name, + self.node, + t, + ) if scn is not None: - df = self.agg.network(self.network).node(self.node).storage(self.name).scn(scn).time() + df = ( + self.agg.network(self.network) + .node(self.node) + .storage(self.name) + .scn(scn) + .time() + ) df.sort_index(ascending=True, inplace=True) - y = (df['flow_in'] - df['flow_out']).values - title = 'Monotone storage of %s on node %s for scn=%0d' % (self.name, self.node, scn) + y = (df["flow_in"] - df["flow_out"]).values + title = "Monotone storage of %s on node %s for scn=%0d" % ( + self.name, + self.node, + scn, + ) return self.plotting.monotone(y, title) @@ -286,8 +447,16 @@ class LinkFluentAPISelector(FluentAPISelector): """ Link level of fluent api """ - def __init__(self, plotting: ABCElementPlotting, agg: ResultAnalyzer, - network: str, src: str, dest: str, kind: str): + + def __init__( + self, + plotting: ABCElementPlotting, + agg: ResultAnalyzer, + network: str, + src: str, + dest: str, + kind: str, + ): FluentAPISelector.__init__(self, plotting, agg) self.src = src self.dest = dest @@ -299,8 +468,14 @@ def timeline(self): Plot timeline graphics. :return: """ - links = self.agg.network(self.network).node(self.src).link(self.dest).scn().time()[self.kind] - title = 'Link %s from %s to %s' % (self.kind, self.src, self.dest) + links = ( + self.agg.network(self.network) + .node(self.src) + .link(self.dest) + .scn() + .time()[self.kind] + ) + title = "Link %s from %s to %s" % (self.kind, self.src, self.dest) return self.plotting.timeline(links, title) def monotone(self, t: int = None, scn: int = None): @@ -314,11 +489,29 @@ def monotone(self, t: int = None, scn: int = None): FluentAPISelector.not_both(t, scn) if t is not None: - y = self.agg.network(self.network).node(self.src).link(self.dest).time(t).scn()[self.kind].values - title = 'Monotone link from %s to %s at t=%0d' % (self.src, self.dest, t) + y = ( + self.agg.network(self.network) + .node(self.src) + .link(self.dest) + .time(t) + .scn()[self.kind] + .values + ) + title = "Monotone link from %s to %s at t=%0d" % (self.src, self.dest, t) elif scn is not None: - y = self.agg.network(self.network).node(self.src).link(self.dest).scn(scn).time()[self.kind].values - title = 'Monotone link from %s to %s at scn=%0d' % (self.src, self.dest, scn) + y = ( + self.agg.network(self.network) + .node(self.src) + .link(self.dest) + .scn(scn) + .time()[self.kind] + .values + ) + title = "Monotone link from %s to %s at scn=%0d" % ( + self.src, + self.dest, + scn, + ) return self.plotting.monotone(y, title) @@ -333,13 +526,31 @@ def gaussian(self, t: int = None, scn: int = None): FluentAPISelector.not_both(t, scn) if t is None: - prod = self.agg.network(self.network).node(self.src).link(self.dest).scn(scn).time()[self.kind].values + prod = ( + self.agg.network(self.network) + .node(self.src) + .link(self.dest) + .scn(scn) + .time()[self.kind] + .values + ) rac = self.agg.get_rac(network=self.network)[scn, :] - title = 'Gaussian link from %s to %s at scn=%0d' % (self.src, self.dest, scn) + title = "Gaussian link from %s to %s at scn=%0d" % ( + self.src, + self.dest, + scn, + ) elif scn is None: - prod = self.agg.network(self.network).node(self.src).link(self.dest).time(t).scn()[self.kind].values + prod = ( + self.agg.network(self.network) + .node(self.src) + .link(self.dest) + .time(t) + .scn()[self.kind] + .values + ) rac = self.agg.get_rac(network=self.network)[:, t] - title = 'Gaussian link from %s to %s at t=%0d' % (self.src, self.dest, t) + title = "Gaussian link from %s to %s at t=%0d" % (self.src, self.dest, t) return self.plotting.gaussian(rac=rac, qt=prod, title=title) @@ -348,8 +559,15 @@ class SrcConverterFluentAPISelector(FluentAPISelector): """ Source converter level of fluent api """ - def __init__(self, plotting: ABCElementPlotting, agg: ResultAnalyzer, - network: str, node: str, name: str): + + def __init__( + self, + plotting: ABCElementPlotting, + agg: ResultAnalyzer, + network: str, + node: str, + name: str, + ): FluentAPISelector.__init__(self, plotting, agg) self.node = node self.name = name @@ -360,8 +578,14 @@ def timeline(self): Plot timeline graphics. :return: """ - links = self.agg.network(self.network).node(self.node).to_converter(self.name).scn().time()['flow'] - title = 'Timeline converter %s from node %s' % (self.name, self.node) + links = ( + self.agg.network(self.network) + .node(self.node) + .to_converter(self.name) + .scn() + .time()["flow"] + ) + title = "Timeline converter %s from node %s" % (self.name, self.node) return self.plotting.timeline(links, title) def monotone(self, t: int = None, scn: int = None): @@ -375,11 +599,33 @@ def monotone(self, t: int = None, scn: int = None): FluentAPISelector.not_both(t, scn) if t is not None: - y = self.agg.network(self.network).node(self.node).to_converter(self.name).time(t).scn()['flow'].values - title = 'Timeline converter %s from node %s at t=%0d' % (self.name, self.node, t) + y = ( + self.agg.network(self.network) + .node(self.node) + .to_converter(self.name) + .time(t) + .scn()["flow"] + .values + ) + title = "Timeline converter %s from node %s at t=%0d" % ( + self.name, + self.node, + t, + ) elif scn is not None: - y = self.agg.network(self.network).node(self.node).to_converter(self.name).scn(scn).time()['flow'].values - title = 'Timeline converter %s from node %s at scn=%0d' % (self.name, self.node, scn) + y = ( + self.agg.network(self.network) + .node(self.node) + .to_converter(self.name) + .scn(scn) + .time()["flow"] + .values + ) + title = "Timeline converter %s from node %s at scn=%0d" % ( + self.name, + self.node, + scn, + ) return self.plotting.monotone(y, title) @@ -394,13 +640,35 @@ def gaussian(self, t: int = None, scn: int = None): FluentAPISelector.not_both(t, scn) if t is None: - prod = self.agg.network(self.network).node(self.node).to_converter(self.name).time(t).scn()['flow'].values + prod = ( + self.agg.network(self.network) + .node(self.node) + .to_converter(self.name) + .time(t) + .scn()["flow"] + .values + ) rac = self.agg.get_rac(network=self.network)[scn, :] - title = 'Gaussian converter %s from node %s at scn=%0d' % (self.name, self.node, scn) + title = "Gaussian converter %s from node %s at scn=%0d" % ( + self.name, + self.node, + scn, + ) elif scn is None: - prod = self.agg.network(self.network).node(self.node).to_converter(self.name).time(t).scn()['flow'].values + prod = ( + self.agg.network(self.network) + .node(self.node) + .to_converter(self.name) + .time(t) + .scn()["flow"] + .values + ) rac = self.agg.get_rac(network=self.network)[:, t] - title = 'Gaussian converter %s from node %s at t=%0d' % (self.name, self.node, t) + title = "Gaussian converter %s from node %s at t=%0d" % ( + self.name, + self.node, + t, + ) return self.plotting.gaussian(rac=rac, qt=prod, title=title) @@ -409,8 +677,15 @@ class DestConverterFluentAPISelector(FluentAPISelector): """ Source converter level of fluent api """ - def __init__(self, plotting: ABCElementPlotting, agg: ResultAnalyzer, - network: str, node: str, name: str): + + def __init__( + self, + plotting: ABCElementPlotting, + agg: ResultAnalyzer, + network: str, + node: str, + name: str, + ): FluentAPISelector.__init__(self, plotting, agg) self.node = node self.name = name @@ -421,8 +696,14 @@ def timeline(self): Plot timeline graphics. :return: """ - links = self.agg.network(self.network).node(self.node).from_converter(self.name).scn().time()['flow'] - title = 'Timeline converter %s to node %s' % (self.name, self.node) + links = ( + self.agg.network(self.network) + .node(self.node) + .from_converter(self.name) + .scn() + .time()["flow"] + ) + title = "Timeline converter %s to node %s" % (self.name, self.node) return self.plotting.timeline(links, title) def monotone(self, t: int = None, scn: int = None): @@ -436,11 +717,33 @@ def monotone(self, t: int = None, scn: int = None): FluentAPISelector.not_both(t, scn) if t is not None: - y = self.agg.network(self.network).node(self.node).from_converter(self.name).time(t).scn()['flow'].values - title = 'Timeline converter %s to node %s at t=%0d' % (self.name, self.node, t) + y = ( + self.agg.network(self.network) + .node(self.node) + .from_converter(self.name) + .time(t) + .scn()["flow"] + .values + ) + title = "Timeline converter %s to node %s at t=%0d" % ( + self.name, + self.node, + t, + ) elif scn is not None: - y = self.agg.network(self.network).node(self.node).from_converter(self.name).scn(scn).time()['flow'].values - title = 'Timeline converter %s to node %s at scn=%0d' % (self.name, self.node, scn) + y = ( + self.agg.network(self.network) + .node(self.node) + .from_converter(self.name) + .scn(scn) + .time()["flow"] + .values + ) + title = "Timeline converter %s to node %s at scn=%0d" % ( + self.name, + self.node, + scn, + ) return self.plotting.monotone(y, title) @@ -455,13 +758,35 @@ def gaussian(self, t: int = None, scn: int = None): FluentAPISelector.not_both(t, scn) if t is None: - prod = self.agg.network(self.network).node(self.node).from_converter(self.name).time(t).scn()['flow'].values + prod = ( + self.agg.network(self.network) + .node(self.node) + .from_converter(self.name) + .time(t) + .scn()["flow"] + .values + ) rac = self.agg.get_rac(network=self.network)[scn, :] - title = 'Gaussian converter %s to node %s at scn=%0d' % (self.name, self.node, scn) + title = "Gaussian converter %s to node %s at scn=%0d" % ( + self.name, + self.node, + scn, + ) elif scn is None: - prod = self.agg.network(self.network).node(self.node).from_converter(self.name).time(t).scn()['flow'].values + prod = ( + self.agg.network(self.network) + .node(self.node) + .from_converter(self.name) + .time(t) + .scn()["flow"] + .values + ) rac = self.agg.get_rac(network=self.network)[:, t] - title = 'Gaussian converter %s to node %s at t=%0d' % (self.name, self.node, t) + title = "Gaussian converter %s to node %s at t=%0d" % ( + self.name, + self.node, + t, + ) return self.plotting.gaussian(rac=rac, qt=prod, title=title) @@ -470,12 +795,15 @@ class NodeFluentAPISelector(FluentAPISelector): """ Node level of fluent api """ - def __init__(self, plotting: ABCElementPlotting, agg: ResultAnalyzer, network: str, node: str): + + def __init__( + self, plotting: ABCElementPlotting, agg: ResultAnalyzer, network: str, node: str + ): FluentAPISelector.__init__(self, plotting, agg) self.node = node self.network = network - def stack(self, scn: int = 0, prod_kind: str = 'used', cons_kind: str = 'asked'): + def stack(self, scn: int = 0, prod_kind: str = "used", cons_kind: str = "asked"): """ Plot with production stacked with area and consumptions stacked by dashed lines. @@ -485,46 +813,106 @@ def stack(self, scn: int = 0, prod_kind: str = 'used', cons_kind: str = 'asked') :param cons_kind: select which cons to stack : 'asked' or 'given' :return: plotly figure or jupyter widget to plot """ - def extract(query, value_col: str, sort_col: Optional[str] = 'cost', id_col: str = 'name'): + + def extract( + query, + value_col: str, + sort_col: Optional[str] = "cost", + id_col: str = "name", + ): data = query.time() if sort_col: data.sort_values(sort_col, ascending=False, inplace=True) ids = data.index.get_level_values(id_col).unique() return [(i, data.loc[i][value_col].sort_index().values) for i in ids] - c, p, s, b, ve, vi = self.agg.get_elements_inside(node=self.node, network=self.network) + c, p, s, b, ve, vi = self.agg.get_elements_inside( + node=self.node, network=self.network + ) areas = [] - areas += extract(query=self.agg.network(self.network).scn(scn).node(self.node).production(), - value_col=prod_kind) if p > 0 else [] - areas += extract(query=self.agg.network(self.network).scn(scn).node(self.node).storage(), - value_col='flow_out') if s > 0 else [] - areas += extract(query=self.agg.network(self.network).scn(scn).node(self.node).from_converter(), - value_col='flow', sort_col=None) if vi > 0 else [] + areas += ( + extract( + query=self.agg.network(self.network) + .scn(scn) + .node(self.node) + .production(), + value_col=prod_kind, + ) + if p > 0 + else [] + ) + areas += ( + extract( + query=self.agg.network(self.network).scn(scn).node(self.node).storage(), + value_col="flow_out", + ) + if s > 0 + else [] + ) + areas += ( + extract( + query=self.agg.network(self.network) + .scn(scn) + .node(self.node) + .from_converter(), + value_col="flow", + sort_col=None, + ) + if vi > 0 + else [] + ) # add import in production stack balance = self.agg.get_balance(node=self.node, network=self.network)[scn] im = -np.clip(balance, None, 0) if not (im == 0).all(): - areas.append(('import', im)) + areas.append(("import", im)) lines = [] - lines += extract(query=self.agg.network(self.network).scn(scn).node(self.node).consumption(), - value_col=cons_kind) if c > 0 else [] - lines += extract(query=self.agg.network(self.network).scn(scn).node(self.node).storage(), - value_col='flow_in') if s > 0 else [] - lines += extract(query=self.agg.network(self.network).scn(scn).node(self.node).to_converter(), - value_col='flow', sort_col=None) if ve > 0 else [] + lines += ( + extract( + query=self.agg.network(self.network) + .scn(scn) + .node(self.node) + .consumption(), + value_col=cons_kind, + ) + if c > 0 + else [] + ) + lines += ( + extract( + query=self.agg.network(self.network).scn(scn).node(self.node).storage(), + value_col="flow_in", + ) + if s > 0 + else [] + ) + lines += ( + extract( + query=self.agg.network(self.network) + .scn(scn) + .node(self.node) + .to_converter(), + value_col="flow", + sort_col=None, + ) + if ve > 0 + else [] + ) # Add export in consumption stack exp = np.clip(balance, 0, None) if not (exp == 0).all(): - lines.append(('export', exp)) + lines.append(("export", exp)) - title = 'Stack for node %s' % self.node + title = "Stack for node %s" % self.node return self.plotting.stack(areas, lines, title) - def consumption(self, name: str, kind: str = 'given') -> ConsumptionFluentAPISelector: + def consumption( + self, name: str, kind: str = "given" + ) -> ConsumptionFluentAPISelector: """ Go to consumption level of fluent API @@ -532,19 +920,31 @@ def consumption(self, name: str, kind: str = 'given') -> ConsumptionFluentAPISel :param kind: kind of data 'asked' or 'given' :return: """ - return ConsumptionFluentAPISelector(plotting=self.plotting, agg=self.agg, - network=self.network, node=self.node, name=name, kind=kind) + return ConsumptionFluentAPISelector( + plotting=self.plotting, + agg=self.agg, + network=self.network, + node=self.node, + name=name, + kind=kind, + ) - def production(self, name: str, kind: str = 'used') -> ProductionFluentAPISelector: + def production(self, name: str, kind: str = "used") -> ProductionFluentAPISelector: """ - Go to production level of fluent API + Go to production level of fluent API - :param name: select production name - :param kind: kind of data available ('avail') or 'used' - :return: - """ - return ProductionFluentAPISelector(plotting=self.plotting, agg=self.agg, - network=self.network, node=self.node, name=name, kind=kind) + :param name: select production name + :param kind: kind of data available ('avail') or 'used' + :return: + """ + return ProductionFluentAPISelector( + plotting=self.plotting, + agg=self.agg, + network=self.network, + node=self.node, + name=name, + kind=kind, + ) def storage(self, name: str) -> StorageFluentAPISelector: """ @@ -553,19 +953,30 @@ def storage(self, name: str) -> StorageFluentAPISelector: :param name: select storage name :return: """ - return StorageFluentAPISelector(plotting=self.plotting, agg=self.agg, - network=self.network, node=self.node, name=name) + return StorageFluentAPISelector( + plotting=self.plotting, + agg=self.agg, + network=self.network, + node=self.node, + name=name, + ) - def link(self, dest: str, kind: str = 'used'): + def link(self, dest: str, kind: str = "used"): """ - got to link level of fluent API + got to link level of fluent API - :param dest: select destination node name - :param kind: kind of data available ('avail') or 'used' - :return: - """ - return LinkFluentAPISelector(plotting=self.plotting, agg=self.agg, - network=self.network, src=self.node, dest=dest, kind=kind) + :param dest: select destination node name + :param kind: kind of data available ('avail') or 'used' + :return: + """ + return LinkFluentAPISelector( + plotting=self.plotting, + agg=self.agg, + network=self.network, + src=self.node, + dest=dest, + kind=kind, + ) def to_converter(self, name: str): """ @@ -573,8 +984,13 @@ def to_converter(self, name: str): :param name: :return: """ - return SrcConverterFluentAPISelector(plotting=self.plotting, agg=self.agg, network=self.network, - node=self.node, name=name) + return SrcConverterFluentAPISelector( + plotting=self.plotting, + agg=self.agg, + network=self.network, + node=self.node, + name=name, + ) def from_converter(self, name: str): """ @@ -582,8 +998,13 @@ def from_converter(self, name: str): :param name: :return: """ - return DestConverterFluentAPISelector(plotting=self.plotting, agg=self.agg, network=self.network, - node=self.node, name=name) + return DestConverterFluentAPISelector( + plotting=self.plotting, + agg=self.agg, + network=self.network, + node=self.node, + name=name, + ) class NetworkFluentAPISelector(FluentAPISelector): @@ -617,7 +1038,10 @@ def map(self, t: int, zoom: int, scn: int = 0, limit: int = None): :param limit: color scale limite to use :return: """ - nodes = {node: self.agg.get_balance(node=node, network=self.network)[scn, t] for node in self.agg.nodes(self.network)} + nodes = { + node: self.agg.get_balance(node=node, network=self.network)[scn, t] + for node in self.agg.nodes(self.network) + } if limit is None: limit = max(max(nodes.values()), -min(nodes.values())) @@ -625,17 +1049,19 @@ def map(self, t: int, zoom: int, scn: int = 0, limit: int = None): lines = {} # Compute lines links = self.agg.network(self.network).scn(scn).time(t).node().link() - for src in links.index.get_level_values('node').unique(): - for dest in links.loc[src].index.get_level_values('dest').unique(): - exchange = links.loc[src, dest]['used'] # forward - exchange -= links.loc[dest, src]['used'] if (dest, src) in links.index else 0 # backward + for src in links.index.get_level_values("node").unique(): + for dest in links.loc[src].index.get_level_values("dest").unique(): + exchange = links.loc[src, dest]["used"] # forward + exchange -= ( + links.loc[dest, src]["used"] if (dest, src) in links.index else 0 + ) # backward if exchange >= 0: lines[(src, dest)] = exchange else: lines[(dest, src)] = -exchange - title = 'Exchange map at t=%0d scn=%0d' % (t, scn) + title = "Exchange map at t=%0d scn=%0d" % (t, scn) return self.plotting.map_exchange(nodes, lines, limit, title, zoom) def node(self, node: str): @@ -644,7 +1070,9 @@ def node(self, node: str): :param node: node name :return: NodeFluentAPISelector """ - return NodeFluentAPISelector(plotting=self.plotting, agg=self.agg, node=node, network=self.network) + return NodeFluentAPISelector( + plotting=self.plotting, agg=self.agg, node=node, network=self.network + ) class ABCPlotting(ABC): @@ -652,10 +1080,14 @@ class ABCPlotting(ABC): Abstract method to plot optimizer result. """ - def __init__(self, agg: ResultAnalyzer, - unit_symbol: str = '', - time_start=None, time_end=None, - node_coord: Dict[str, List[float]] = None): + def __init__( + self, + agg: ResultAnalyzer, + unit_symbol: str = "", + time_start=None, + time_end=None, + node_coord: Dict[str, List[float]] = None, + ): """ Create instance. @@ -668,23 +1100,27 @@ def __init__(self, agg: ResultAnalyzer, """ self.plotting = None self.agg = agg - self.unit = '(%s)' % unit_symbol if unit_symbol != '' else '' + self.unit = "(%s)" % unit_symbol if unit_symbol != "" else "" self.coord = node_coord # Create time_index time = [time_start is None, time_end is None] if time == [True, False] or time == [False, True]: - raise ValueError('You have to give both time_start and time_end') + raise ValueError("You have to give both time_start and time_end") elif time == [False, False]: - self.time_index = pd.date_range(start=time_start, end=time_end, periods=self.agg.horizon) + self.time_index = pd.date_range( + start=time_start, end=time_end, periods=self.agg.horizon + ) else: self.time_index = np.arange(self.agg.horizon) - def network(self, network: str = 'default'): + def network(self, network: str = "default"): """ Entry point to use fluent API. :param network: select network to anlyze. Default is 'default' :return: NetworkFluentAPISelector """ - return NetworkFluentAPISelector(plotting=self.plotting, agg=self.agg, network=network) + return NetworkFluentAPISelector( + plotting=self.plotting, agg=self.agg, network=network + ) diff --git a/hadar/viewer/html.py b/hadar/viewer/html.py index 7aaaf4e..e791890 100644 --- a/hadar/viewer/html.py +++ b/hadar/viewer/html.py @@ -15,11 +15,13 @@ from hadar.analyzer.result import ResultAnalyzer from hadar.viewer.abc import ABCPlotting, ABCElementPlotting -__all__ = ['HTMLPlotting'] +__all__ = ["HTMLPlotting"] class HTMLElementPlotting(ABCElementPlotting): - def __init__(self, unit: str, time_index, node_coord: Dict[str, List[float]] = None): + def __init__( + self, unit: str, time_index, node_coord: Dict[str, List[float]] = None + ): self.unit = unit self.time_index = time_index self.coord = node_coord @@ -27,8 +29,18 @@ def __init__(self, unit: str, time_index, node_coord: Dict[str, List[float]] = N self.cmap = coolwarm self.cmap_plotly = HTMLElementPlotting.matplotlib_to_plotly(self.cmap, 255) - self.cmap_cons = ['brown', 'blue', 'darkgoldenrod', 'darkmagenta', 'darkorange', 'cadetblue', 'forestgreen', - 'indigo', 'olive', 'darkred'] + self.cmap_cons = [ + "brown", + "blue", + "darkgoldenrod", + "darkmagenta", + "darkorange", + "cadetblue", + "forestgreen", + "indigo", + "olive", + "darkred", + ] @classmethod def matplotlib_to_plotly(cls, cmap, res: int): @@ -43,21 +55,33 @@ def matplotlib_to_plotly(cls, cmap, res: int): pl_colorscale = [] for k in range(res): C = (np.array(cmap(k * h)[:3]) * 255).astype(np.uint8) - pl_colorscale.append([k * h, 'rgb' + str((C[0], C[1], C[2]))]) + pl_colorscale.append([k * h, "rgb" + str((C[0], C[1], C[2]))]) return pl_colorscale def timeline(self, df: pd.DataFrame, title: str): - scenarios = df.index.get_level_values('scn').unique() + scenarios = df.index.get_level_values("scn").unique() alpha = max(0.01, 1 / scenarios.size) - color = 'rgba(0, 0, 0, %.2f)' % alpha + color = "rgba(0, 0, 0, %.2f)" % alpha fig = go.Figure() for scn in scenarios: - fig.add_trace(go.Scatter(x=self.time_index, y=df.loc[scn], mode='lines', hoverinfo='name', - name='scn %0d' % scn, line=dict(color=color))) - - fig.update_layout(title_text=title, - yaxis_title="Quantity %s" % self.unit, xaxis_title="time", showlegend=False) + fig.add_trace( + go.Scatter( + x=self.time_index, + y=df.loc[scn], + mode="lines", + hoverinfo="name", + name="scn %0d" % scn, + line=dict(color=color), + ) + ) + + fig.update_layout( + title_text=title, + yaxis_title="Quantity %s" % self.unit, + xaxis_title="time", + showlegend=False, + ) return fig @@ -67,9 +91,13 @@ def monotone(self, y: np.ndarray, title: str): x = np.linspace(0, 100, y.size) fig = go.Figure() - fig.add_trace(go.Scatter(x=x, y=y, mode='markers')) - fig.update_layout(title_text=title, - yaxis_title="Quantity %s" % self.unit, xaxis_title="%", showlegend=False) + fig.add_trace(go.Scatter(x=x, y=y, mode="markers")) + fig.update_layout( + title_text=title, + yaxis_title="Quantity %s" % self.unit, + xaxis_title="%", + showlegend=False, + ) return fig @@ -88,36 +116,94 @@ def _gaussian(x, m, o): red = qt[rac < 0] fig = go.Figure() - fig.add_trace(go.Scatter(x=x, y=_gaussian(x, m, o), mode='lines', hoverinfo='none', line=dict(color='grey'))) - fig.add_trace(go.Scatter(x=green, y=_gaussian(green, m, o), hovertemplate='%{x:.2f} ' + self.unit, - name='passed', mode='markers', marker=dict(color='green', size=10))) - fig.add_trace(go.Scatter(x=red, y=_gaussian(red, m, o), hovertemplate='%{x:.2f} ' + self.unit, - name='failed', mode='markers', marker=dict(color='red', size=10))) - fig.update_layout(title_text=title, yaxis=dict(visible=False), - yaxis_title='', xaxis_title="Quantity %s" % self.unit, showlegend=False) + fig.add_trace( + go.Scatter( + x=x, + y=_gaussian(x, m, o), + mode="lines", + hoverinfo="none", + line=dict(color="grey"), + ) + ) + fig.add_trace( + go.Scatter( + x=green, + y=_gaussian(green, m, o), + hovertemplate="%{x:.2f} " + self.unit, + name="passed", + mode="markers", + marker=dict(color="green", size=10), + ) + ) + fig.add_trace( + go.Scatter( + x=red, + y=_gaussian(red, m, o), + hovertemplate="%{x:.2f} " + self.unit, + name="failed", + mode="markers", + marker=dict(color="red", size=10), + ) + ) + fig.update_layout( + title_text=title, + yaxis=dict(visible=False), + yaxis_title="", + xaxis_title="Quantity %s" % self.unit, + showlegend=False, + ) return fig def candles(self, open: np.ndarray, close: np.ndarray, title: str): fig = go.Figure() - text = ['%s
Begin=%d
End=%d
Flow=%d' % (t, o, c, c-o) for o, c, t in zip(open, close, self.time_index)] - fig.add_trace(go.Ohlc(x=self.time_index, open=open, high=open, low=close, close=close, - hoverinfo='text', text=text)) - - fig.update_layout(title_text=title, yaxis_title='Quantity %s' % self.unit, xaxis_rangeslider_visible=False, - xaxis_title='Time', showlegend=False) + text = [ + "%s
Begin=%d
End=%d
Flow=%d" % (t, o, c, c - o) + for o, c, t in zip(open, close, self.time_index) + ] + fig.add_trace( + go.Ohlc( + x=self.time_index, + open=open, + high=open, + low=close, + close=close, + hoverinfo="text", + text=text, + ) + ) + + fig.update_layout( + title_text=title, + yaxis_title="Quantity %s" % self.unit, + xaxis_rangeslider_visible=False, + xaxis_title="Time", + showlegend=False, + ) return fig - def stack(self, areas: List[Tuple[str, np.ndarray]], lines: List[Tuple[str, np.ndarray]], title: str): + def stack( + self, + areas: List[Tuple[str, np.ndarray]], + lines: List[Tuple[str, np.ndarray]], + title: str, + ): fig = go.Figure() # Stack areas stack = np.zeros_like(self.time_index, dtype=float) for i, (name, data) in enumerate(areas): stack += data - fig.add_trace(go.Scatter(x=self.time_index, y=stack.copy(), name=name, mode='none', - fill='tozeroy' if i == 0 else 'tonexty')) + fig.add_trace( + go.Scatter( + x=self.time_index, + y=stack.copy(), + name=name, + mode="none", + fill="tozeroy" if i == 0 else "tonexty", + ) + ) # Stack lines. # Bottom line have to be top frontward. So we firstly stack lines then plot in reverse set. @@ -128,10 +214,19 @@ def stack(self, areas: List[Tuple[str, np.ndarray]], lines: List[Tuple[str, np.n stacked_lines.append((name, stack.copy())) for i, (name, data) in enumerate(stacked_lines[::-1]): - fig.add_trace(go.Scatter(x=self.time_index, y=data, line_color=self.cmap_cons[i % 10], - name=name, line=dict(width=2))) - - fig.update_layout(title_text=title, yaxis_title="Quantity %s" % self.unit, xaxis_title="time") + fig.add_trace( + go.Scatter( + x=self.time_index, + y=data, + line_color=self.cmap_cons[i % 10], + name=name, + line=dict(width=2), + ) + ) + + fig.update_layout( + title_text=title, yaxis_title="Quantity %s" % self.unit, xaxis_title="time" + ) return fig def matrix(self, data: np.ndarray, title): @@ -140,22 +235,33 @@ def sdt(x): x[x < 0] /= -np.min(x[x < 0]) return x - fig = go.Figure(data=go.Heatmap( - z=sdt(data.copy()), - x=self.time_index, - y=np.arange(data.shape[0]), - hoverinfo='text', - text=data, - colorscale='RdBu', zmid=0, - showscale=False)) - - fig.update_layout(title_text=title, yaxis_title="scenarios", xaxis_title="time", showlegend=False) + fig = go.Figure( + data=go.Heatmap( + z=sdt(data.copy()), + x=self.time_index, + y=np.arange(data.shape[0]), + hoverinfo="text", + text=data, + colorscale="RdBu", + zmid=0, + showscale=False, + ) + ) + + fig.update_layout( + title_text=title, + yaxis_title="scenarios", + xaxis_title="time", + showlegend=False, + ) return fig def map_exchange(self, nodes, lines, limit, title, size): if self.coord is None: - raise ValueError('Please provide node coordinate by setting param node_coord in Plotting constructor') + raise ValueError( + "Please provide node coordinate by setting param node_coord in Plotting constructor" + ) fig = go.Figure() # Add node circle @@ -166,27 +272,42 @@ def map_exchange(self, nodes, lines, limit, title, size): # Plot arrows for (src, dest), qt in lines.items(): - color = 'rgb' + str(self.cmap(abs(qt) / 2 / limit + 0.5)[:-1]) + color = "rgb" + str(self.cmap(abs(qt) / 2 / limit + 0.5)[:-1]) self._plot_links(fig, src, dest, color, qt, size) # Plot nodes - fig.add_trace(go.Scattermapbox( - mode="markers", - lon=node_coords[:, 0], - lat=node_coords[:, 1], - hoverinfo='text', text=node_qt, - marker=dict(size=20, colorscale=self.cmap_plotly, cmin=-limit, color=node_qt, - cmax=limit, colorbar_title="Net Position %s" % self.unit))) - - fig.update_layout(showlegend=False, - title_text=title, - mapbox=dict( - style="carto-positron", - center={'lon': center[0], 'lat': center[1]}, - zoom=1 / size / 0.07)) + fig.add_trace( + go.Scattermapbox( + mode="markers", + lon=node_coords[:, 0], + lat=node_coords[:, 1], + hoverinfo="text", + text=node_qt, + marker=dict( + size=20, + colorscale=self.cmap_plotly, + cmin=-limit, + color=node_qt, + cmax=limit, + colorbar_title="Net Position %s" % self.unit, + ), + ) + ) + + fig.update_layout( + showlegend=False, + title_text=title, + mapbox=dict( + style="carto-positron", + center={"lon": center[0], "lat": center[1]}, + zoom=1 / size / 0.07, + ), + ) return fig - def _plot_links(self, fig: go.Figure, start: str, end: str, color: str, qt: float, size: float): + def _plot_links( + self, fig: go.Figure, start: str, end: str, color: str, qt: float, size: float + ): """ Plot line with arrow to a figure. @@ -201,9 +322,15 @@ def _plot_links(self, fig: go.Figure, start: str, end: str, color: str, qt: floa E = np.array([self.coord[end][0], self.coord[end][1]]) # plot line - fig.add_trace(go.Scattermapbox(lat=[S[1], E[1]], hoverinfo='skip', - lon=[S[0], E[0]], mode='lines', - line=dict(width=2 * size, color=color))) + fig.add_trace( + go.Scattermapbox( + lat=[S[1], E[1]], + hoverinfo="skip", + lon=[S[0], E[0]], + mode="lines", + line=dict(width=2 * size, color=color), + ) + ) # vector flow direction v = E - S n = np.linalg.norm(v) @@ -215,9 +342,17 @@ def _plot_links(self, fig: go.Figure, start: str, end: str, color: str, qt: floa C = A - v / 10 + w / 10 # plot arrow - fig.add_trace(go.Scattermapbox(lat=[B[1], A[1], C[1], B[1], None], hoverinfo='text', fill='toself', - lon=[B[0], A[0], C[0], B[0], None], text=str(qt), mode='lines', - line=dict(width=2 * size, color=color))) + fig.add_trace( + go.Scattermapbox( + lat=[B[1], A[1], C[1], B[1], None], + hoverinfo="text", + fill="toself", + lon=[B[0], A[0], C[0], B[0], None], + text=str(qt), + mode="lines", + line=dict(width=2 * size, color=color), + ) + ) class HTMLPlotting(ABCPlotting): @@ -225,9 +360,14 @@ class HTMLPlotting(ABCPlotting): Plotting implementation interactive html graphics. (Use plotly) """ - def __init__(self, agg: ResultAnalyzer, unit_symbol: str = '', - time_start=None, time_end=None, - node_coord: Dict[str, List[float]] = None): + def __init__( + self, + agg: ResultAnalyzer, + unit_symbol: str = "", + time_start=None, + time_end=None, + node_coord: Dict[str, List[float]] = None, + ): """ Create instance. @@ -239,5 +379,3 @@ def __init__(self, agg: ResultAnalyzer, unit_symbol: str = '', """ ABCPlotting.__init__(self, agg, unit_symbol, time_start, time_end, node_coord) self.plotting = HTMLElementPlotting(self.unit, self.time_index, self.coord) - - diff --git a/hadar/workflow/__init__.py b/hadar/workflow/__init__.py index 84711aa..f76a769 100644 --- a/hadar/workflow/__init__.py +++ b/hadar/workflow/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/hadar/workflow/pipeline.py b/hadar/workflow/pipeline.py index 5f5c045..bdc32e6 100644 --- a/hadar/workflow/pipeline.py +++ b/hadar/workflow/pipeline.py @@ -15,10 +15,22 @@ from hadar.optimizer.utils import DTO -__all__ = ['RestrictedPlug', 'FreePlug', 'Stage', 'FocusStage', 'Drop', 'Rename', 'Fault', 'RepeatScenario', - 'ToShuffler', 'Pipeline', 'Clip'] +__all__ = [ + "RestrictedPlug", + "FreePlug", + "Stage", + "FocusStage", + "Drop", + "Rename", + "Fault", + "RepeatScenario", + "ToShuffler", + "Pipeline", + "Clip", +] + +TO_SHUFFLER = "to_shuffler" -TO_SHUFFLER = 'to_shuffler' class Plug(ABC, DTO): """ @@ -145,6 +157,7 @@ class Pipeline: """ Compute many stages sequentially. """ + def __init__(self, stages: List): """ Instance new pipeline. @@ -159,8 +172,10 @@ def __init__(self, stages: List): for i in range(0, len(stages) - 1): curr, next = stages[i], stages[i + 1] if not curr.plug.linkable_to(next.plug): - raise ValueError("Pipeline can't be added current outputs are %s and %s has input %s" % - (self.plug.outputs, curr.__class__.__name__, next.plug.inputs)) + raise ValueError( + "Pipeline can't be added current outputs are %s and %s has input %s" + % (self.plug.outputs, curr.__class__.__name__, next.plug.inputs) + ) self.plug += next.plug @@ -175,8 +190,10 @@ def __add__(self, other): raise ValueError("You can link Pipeline only with new Stage object") if not self.plug.linkable_to(other.plug): - raise ValueError("Pipeline can't be added current outputs are %s and %s has input %s" % - (self.plug.outputs, other.__class__.__name__, other.plug.inputs)) + raise ValueError( + "Pipeline can't be added current outputs are %s and %s has input %s" + % (self.plug.outputs, other.__class__.__name__, other.plug.inputs) + ) self.plug += other.plug self.stages.append(other) @@ -207,11 +224,17 @@ def assert_computable(self, timeline: pd.DataFrame): """ names = Stage.get_names(timeline) if not self.plug.computable(names): - raise ValueError("Pipeline accept %s in input, but receive %s" % (self.plug.inputs, names)) + raise ValueError( + "Pipeline accept %s in input, but receive %s" + % (self.plug.inputs, names) + ) def assert_to_shuffler(self): if TO_SHUFFLER not in self.plug.outputs: - raise ValueError("Pipeline output must have a 'to_generate' column, but has %s", self.plug.outputs) + raise ValueError( + "Pipeline output must have a 'to_generate' column, but has %s", + self.plug.outputs, + ) class Stage(ABC): @@ -237,7 +260,9 @@ def __add__(self, other) -> Pipeline: :return: Pipeline with two stage and merged plug """ if not isinstance(other, Stage): - raise ValueError('Only addition with other Stage is accepted not with %s' % type(other)) + raise ValueError( + "Only addition with other Stage is accepted not with %s" % type(other) + ) return Pipeline(stages=[self, other]) @@ -262,11 +287,13 @@ def __call__(self, timeline: pd.DataFrame) -> pd.DataFrame: # If compute run inside multiprocessing like in Shuffler. randomness are not independence. # We need to reseed with urandom - np.random.seed(int.from_bytes(os.urandom(4), byteorder='little')) + np.random.seed(int.from_bytes(os.urandom(4), byteorder="little")) names = Stage.get_names(timeline) if not self.plug.computable(names): - raise ValueError("Stage accept %s in input, but receive %s" % (self.plug.inputs, names)) + raise ValueError( + "Stage accept %s in input, but receive %s" % (self.plug.inputs, names) + ) return self._process_timeline(timeline.copy()) @@ -398,7 +425,12 @@ def __init__(self, **kwargs): :param kwargs: dictionary of strings like Rename(old_name='new_name') """ - Stage.__init__(self, plug=RestrictedPlug(inputs=list(kwargs.keys()), outputs=list(kwargs.values()))) + Stage.__init__( + self, + plug=RestrictedPlug( + inputs=list(kwargs.keys()), outputs=list(kwargs.values()) + ), + ) self.rename = kwargs def _process_timeline(self, timeline: pd.DataFrame) -> pd.DataFrame: @@ -419,6 +451,7 @@ class ToShuffler(Rename): """ To Connect pipeline to shuffler """ + def __init__(self, result_name: str): """ Instance Stage @@ -452,7 +485,14 @@ class Fault(FocusStage): Generate a random fault for each scenarios. """ - def __init__(self, loss: float, occur_freq: float, downtime_min: int, downtime_max, seed: int = None): + def __init__( + self, + loss: float, + occur_freq: float, + downtime_min: int, + downtime_max, + seed: int = None, + ): """ Initiate Stage. @@ -462,7 +502,9 @@ def __init__(self, loss: float, occur_freq: float, downtime_min: int, downtime_m :param downtime_max: maximal downtime (downtime will be toss for each occurred fault) :param seed: random seed. Set only if you want reproduce exactly result. """ - FocusStage.__init__(self, plug=RestrictedPlug(inputs=['quantity'], outputs=['quantity'])) + FocusStage.__init__( + self, plug=RestrictedPlug(inputs=["quantity"], outputs=["quantity"]) + ) self.loss = loss self.occur_freq = occur_freq self.downtime_min = downtime_min @@ -474,15 +516,19 @@ def _process_scenarios(self, n_scn: int, scenario: pd.DataFrame) -> pd.DataFrame np.random.seed(self.seed) horizon = scenario.shape[0] - nb_faults = np.random.choice([0, 1], size=horizon, p=[1 - self.occur_freq, self.occur_freq]).sum() + nb_faults = np.random.choice( + [0, 1], size=horizon, p=[1 - self.occur_freq, self.occur_freq] + ).sum() loss_qt = np.zeros(horizon) faults_begin = np.random.randint(low=0, high=horizon, size=nb_faults) - faults_duration = np.random.randint(low=self.downtime_min, high=self.downtime_max, size=nb_faults) + faults_duration = np.random.randint( + low=self.downtime_min, high=self.downtime_max, size=nb_faults + ) for begin, duration in zip(faults_begin, faults_duration): - loss_qt[begin:(begin + duration)] += self.loss + loss_qt[begin : (begin + duration)] += self.loss scenario._is_copy = False # Avoid SettingCopyWarning - scenario['quantity'] -= loss_qt + scenario["quantity"] -= loss_qt return scenario @@ -505,6 +551,8 @@ def _process_timeline(self, timeline: pd.DataFrame) -> pd.DataFrame: n_scn = Stage.get_scenarios(timeline).size names = Stage.get_names(timeline) - index = Stage.build_multi_index(scenarios=np.arange(0, n_scn * self.n), names=names) + index = Stage.build_multi_index( + scenarios=np.arange(0, n_scn * self.n), names=names + ) return pd.DataFrame(data=data, columns=index) diff --git a/hadar/workflow/shuffler.py b/hadar/workflow/shuffler.py index ebf555c..76ee6f8 100644 --- a/hadar/workflow/shuffler.py +++ b/hadar/workflow/shuffler.py @@ -12,14 +12,15 @@ from hadar.workflow.pipeline import Pipeline, TO_SHUFFLER, Stage -__all__ = ['Shuffler', 'Timeline'] +__all__ = ["Shuffler", "Timeline"] class Timeline: """ Manage data used to generate timeline. Perform sampling too. """ - def __init__(self, data: np.ndarray = None, sampler = randint): + + def __init__(self, data: np.ndarray = None, sampler=randint): """ Instantiate. @@ -55,7 +56,8 @@ class TimelinePipeline(Timeline): """ Manage data timeline from pipeline generation. """ - def __init__(self, data: pd.DataFrame, pipeline: Pipeline, sampler = randint): + + def __init__(self, data: pd.DataFrame, pipeline: Pipeline, sampler=randint): """ Instantiate. @@ -97,6 +99,7 @@ class Shuffler: Receive all data sources like raw matrix or pipeline. Schedule pipeline generation and shuffle all timeline to create scenarios. """ + def __init__(self, sampler=randint): """ Instantiate. @@ -141,7 +144,7 @@ def shuffle(self, nb_scn): """ # Compute pipelines pool = multiprocessing.Pool() - res = pool.map(compute, ((tl, nb_scn, name) for name, tl in self.timelines.items())) + res = pool.map( + compute, ((tl, nb_scn, name) for name, tl in self.timelines.items()) + ) return dict(res) - - diff --git a/requirements.dev.txt b/requirements.dev.txt index 8f3e008..4284a29 100644 --- a/requirements.dev.txt +++ b/requirements.dev.txt @@ -3,4 +3,5 @@ jupyter click setuptools wheel -twine \ No newline at end of file +twine +black \ No newline at end of file diff --git a/requirements.test.txt b/requirements.test.txt index 938c181..f073815 100644 --- a/requirements.test.txt +++ b/requirements.test.txt @@ -1,3 +1,2 @@ -r requirements.dev.txt -coverage -flake8 \ No newline at end of file +coverage \ No newline at end of file From bb18cfdebe398cfdcf0cdf0de35680dc144f6163 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Jolain?= <4466185+FrancoisJ@users.noreply.github.com> Date: Fri, 2 Oct 2020 15:37:55 +0200 Subject: [PATCH 38/38] use black formatter (apply also for tests) --- docs/source/conf.py | 28 +- examples/utils.py | 52 +- setup.py | 8 +- tests/__init__.py | 1 - tests/analyzer/__init__.py | 1 - tests/analyzer/test_result.py | 607 +++++++++++++++-------- tests/optimizer/__init__.py | 1 - tests/optimizer/domain/__init__.py | 1 - tests/optimizer/domain/test_input.py | 277 +++++++---- tests/optimizer/domain/test_numeric.py | 12 +- tests/optimizer/domain/test_output.py | 36 +- tests/optimizer/it/__init__.py | 1 - tests/optimizer/it/test_optimizer.py | 480 +++++++++++------- tests/optimizer/lp/__init__.py | 1 - tests/optimizer/lp/ortools_mock.py | 4 +- tests/optimizer/lp/test_mapper.py | 602 ++++++++++++++++------ tests/optimizer/lp/test_optimizer.py | 460 ++++++++++++----- tests/optimizer/remote/__init__.py | 1 - tests/optimizer/remote/test_optimizer.py | 66 ++- tests/utils.py | 112 +++-- tests/viewer/__init__.py | 1 - tests/viewer/test_html.py | 185 ++++--- tests/workflow/__init__.py | 1 - tests/workflow/test_integration.py | 38 +- tests/workflow/test_pipeline.py | 202 +++++--- tests/workflow/test_shuffler.py | 57 ++- 26 files changed, 2187 insertions(+), 1048 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 596f453..e912ee9 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -13,16 +13,16 @@ import os import sys -sys.path.insert(0, os.path.abspath('../..')) +sys.path.insert(0, os.path.abspath("../..")) import hadar # -- Project information ----------------------------------------------------- -master_doc = 'index' -project = 'hadar-simulator' -copyright = 'Except where otherwise noted, this content is Copyright (c) 2020, RTE (https://www.rte-france.com) and licensed under a CC-BY-4.0 (https://creativecommons.org/licenses/by/4.0/) license.' -author = 'RTE' +master_doc = "index" +project = "hadar-simulator" +copyright = "Except where otherwise noted, this content is Copyright (c) 2020, RTE (https://www.rte-france.com) and licensed under a CC-BY-4.0 (https://creativecommons.org/licenses/by/4.0/) license." +author = "RTE" # The full version, including alpha/beta/rc tags release = hadar.__version__ @@ -34,19 +34,19 @@ # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', - 'nbsphinx', - 'IPython.sphinxext.ipython_console_highlighting', - 'sphinx.ext.mathjax', + "sphinx.ext.autodoc", + "nbsphinx", + "IPython.sphinxext.ipython_console_highlighting", + "sphinx.ext.mathjax", ] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', '**.ipynb_checkpoints'] +exclude_patterns = ["_build", "**.ipynb_checkpoints"] # -- Options for HTML output ------------------------------------------------- @@ -54,12 +54,12 @@ # The theme to use for HTML and HTML Help pages. See the reference for # a list of builtin themes. # -html_theme = 'pydata_sphinx_theme' +html_theme = "pydata_sphinx_theme" html_logo = "_static/logo.png" # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] -nbsphinx_execute = 'never' +nbsphinx_execute = "never" diff --git a/examples/utils.py b/examples/utils.py index 132cc6d..64b21da 100644 --- a/examples/utils.py +++ b/examples/utils.py @@ -15,7 +15,7 @@ from nbconvert.preprocessors import ExecutePreprocessor exporter = RSTExporter() -ep = ExecutePreprocessor(timeout=600, kernel_name='python3', store_widget_state=True) +ep = ExecutePreprocessor(timeout=600, kernel_name="python3", store_widget_state=True) def open_nb(name: str, src: str) -> nbformat: @@ -26,9 +26,11 @@ def open_nb(name: str, src: str) -> nbformat: :param src: source directory :return: notebook object """ - print('Reading...', end=' ') - nb = nbformat.read('{src}/{name}/{name}.ipynb'.format(name=name, src=src), as_version=4) - print('OK', end=' ') + print("Reading...", end=" ") + nb = nbformat.read( + "{src}/{name}/{name}.ipynb".format(name=name, src=src), as_version=4 + ) + print("OK", end=" ") return nb @@ -41,9 +43,9 @@ def execute(nb: nbformat, name: str, src: str) -> nbformat: :param src: notebook source directory (for setup context) :return: notebook object with computed and stored output widget state """ - print('Executing...', end=' ') - ep.preprocess(nb, {'metadata': {'path': '%s/%s/' % (src, name)}}) - print('OK', end=' ') + print("Executing...", end=" ") + ep.preprocess(nb, {"metadata": {"path": "%s/%s/" % (src, name)}}) + print("OK", end=" ") return nb @@ -56,11 +58,11 @@ def copy_image(name: str, export: str, src: str): :param src: source directory :return: None """ - src = '%s/%s' % (src, name) - dest = '%s/%s' % (export, name) - images = [f for f in os.listdir(src) if f.split('.')[-1] in ['png']] + src = "%s/%s" % (src, name) + dest = "%s/%s" % (export, name) + images = [f for f in os.listdir(src) if f.split(".")[-1] in ["png"]] for img in images: - os.rename('%s/%s' % (src, img), '%s/%s' % (dest, img)) + os.rename("%s/%s" % (src, img), "%s/%s" % (dest, img)) def to_export(nb: nbformat, name: str, export: str): @@ -72,17 +74,17 @@ def to_export(nb: nbformat, name: str, export: str): :param export: directory to export :return: None """ - print('Exporting...', end=' ') + print("Exporting...", end=" ") rst, _ = exporter.from_notebook_node(nb) - path = '%s/%s' % (export, name) + path = "%s/%s" % (export, name) if not os.path.exists(path): os.makedirs(path) - with open('%s/%s.rst' % (path, name), 'w') as f: + with open("%s/%s.rst" % (path, name), "w") as f: f.write(rst) - print('OK', end=' ') + print("OK", end=" ") def list_notebook(src: str) -> List[str]: @@ -92,16 +94,20 @@ def list_notebook(src: str) -> List[str]: :return: """ dirs = os.listdir(src) - return [d for d in dirs if os.path.isfile('{src}/{name}/{name}.ipynb'.format(name=d, src=src))] + return [ + d + for d in dirs + if os.path.isfile("{src}/{name}/{name}.ipynb".format(name=d, src=src)) + ] -@click.command('Check and export notebooks') -@click.option('--src', nargs=1, help='Notebook directory') -@click.option('--check', nargs=1, help='check notebook according to result file given') -@click.option('--export', nargs=1, help='export notebooks to directory given') +@click.command("Check and export notebooks") +@click.option("--src", nargs=1, help="Notebook directory") +@click.option("--check", nargs=1, help="check notebook according to result file given") +@click.option("--export", nargs=1, help="export notebooks to directory given") def main(src: str, check: str, export: str): for name in list_notebook(src): - print('{:30}'.format(name), ':', end='') + print("{:30}".format(name), ":", end="") nb = open_nb(name, src) nb = execute(nb, name, src) if check: @@ -109,8 +115,8 @@ def main(src: str, check: str, export: str): if export: to_export(nb, name, export) copy_image(name, export, src) - print('') + print("") -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/setup.py b/setup.py index f82a515..fa74102 100644 --- a/setup.py +++ b/setup.py @@ -4,8 +4,8 @@ with open("README.md", "r") as fh: long_description = fh.read() -with open('requirements.txt', 'r') as f: - dependencies = f.read().split('\n') +with open("requirements.txt", "r") as f: + dependencies = f.read().split("\n") setuptools.setup( name="hadar", @@ -23,5 +23,5 @@ "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", ], - python_requires='>=3.6', -) \ No newline at end of file + python_requires=">=3.6", +) diff --git a/tests/__init__.py b/tests/__init__.py index 84711aa..f76a769 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/tests/analyzer/__init__.py b/tests/analyzer/__init__.py index 84711aa..f76a769 100644 --- a/tests/analyzer/__init__.py +++ b/tests/analyzer/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/tests/analyzer/test_result.py b/tests/analyzer/test_result.py index 9843afb..9155b33 100644 --- a/tests/analyzer/test_result.py +++ b/tests/analyzer/test_result.py @@ -13,78 +13,157 @@ from hadar import LPOptimizer from hadar.analyzer.result import Index, ResultAnalyzer, IntIndex from hadar.optimizer.domain.input import Study -from hadar.optimizer.domain.output import OutputConsumption, OutputLink, OutputNode, OutputProduction, Result, OutputNetwork, \ - OutputStorage, OutputConverter +from hadar.optimizer.domain.output import ( + OutputConsumption, + OutputLink, + OutputNode, + OutputProduction, + Result, + OutputNetwork, + OutputStorage, + OutputConverter, +) class TestIndex(unittest.TestCase): - def test_no_parameters(self): - self.assertEqual(True, Index(column='i').all) + self.assertEqual(True, Index(column="i").all) def test_on_element(self): - i = Index(column='i', index='fr') + i = Index(column="i", index="fr") self.assertEqual(False, i.all) - self.assertEqual(('fr',), i.index) + self.assertEqual(("fr",), i.index) def test_list(self): - i = Index(column='i', index=['fr', 'be']) + i = Index(column="i", index=["fr", "be"]) self.assertEqual(False, i.all) - self.assertEqual(('fr', 'be'), i.index) + self.assertEqual(("fr", "be"), i.index) def test_filter(self): - i = Index(column='i', index=['fr', 'be']) - df = pd.DataFrame(data={'i': ['it', 'fr', 'fr', 'be', 'de', 'it', 'be'], - 'a': [0, 1, 2, 3, 4, 5, 6]}) - - exp = pd.Series(data=[False, True, True, True, False, False, True], index=[0, 1, 2, 3, 4, 5, 6], name='i') + i = Index(column="i", index=["fr", "be"]) + df = pd.DataFrame( + data={ + "i": ["it", "fr", "fr", "be", "de", "it", "be"], + "a": [0, 1, 2, 3, 4, 5, 6], + } + ) + + exp = pd.Series( + data=[False, True, True, True, False, False, True], + index=[0, 1, 2, 3, 4, 5, 6], + name="i", + ) pd.testing.assert_series_equal(exp, i.filter(df)) class TestIntIndex(unittest.TestCase): - def test_range(self): - i = IntIndex('i', index=slice(2, 6)) + i = IntIndex("i", index=slice(2, 6)) self.assertEqual(False, i.all) self.assertEqual((2, 3, 4, 5), i.index) def test_list(self): - i = IntIndex('i', index=[2, 6]) + i = IntIndex("i", index=[2, 6]) self.assertEqual(False, i.all) self.assertEqual((2, 6), i.index) class TestConsumptionAnalyzer(unittest.TestCase): def setUp(self) -> None: - self.study = Study(horizon=3, nb_scn=2)\ - .network()\ - .node('a')\ - .consumption(cost=10 ** 3, quantity=[[120, 12, 12], [12, 120, 120]], name='load')\ - .consumption(cost=10 ** 3, quantity=[[130, 13, 13], [13, 130, 130]], name='car')\ - .node('b')\ - .consumption(cost=10 ** 3, quantity=[[120, 12, 12], [12, 120, 120]], name='load')\ + self.study = ( + Study(horizon=3, nb_scn=2) + .network() + .node("a") + .consumption( + cost=10 ** 3, quantity=[[120, 12, 12], [12, 120, 120]], name="load" + ) + .consumption( + cost=10 ** 3, quantity=[[130, 13, 13], [13, 130, 130]], name="car" + ) + .node("b") + .consumption( + cost=10 ** 3, quantity=[[120, 12, 12], [12, 120, 120]], name="load" + ) .build() + ) out = { - 'a': OutputNode(consumptions=[OutputConsumption(quantity=[[20, 2, 2], [2, 20, 20]], name='load'), - OutputConsumption(quantity=[[30, 3, 3], [3, 30, 30]], name='car')], - productions=[], storages=[], links=[]), - 'b': OutputNode(consumptions=[OutputConsumption(quantity=[[20, 2, 2], [2, 20, 20]], name='load')], - productions=[], storages=[], links=[]) + "a": OutputNode( + consumptions=[ + OutputConsumption(quantity=[[20, 2, 2], [2, 20, 20]], name="load"), + OutputConsumption(quantity=[[30, 3, 3], [3, 30, 30]], name="car"), + ], + productions=[], + storages=[], + links=[], + ), + "b": OutputNode( + consumptions=[ + OutputConsumption(quantity=[[20, 2, 2], [2, 20, 20]], name="load") + ], + productions=[], + storages=[], + links=[], + ), } - self.result = Result(networks={'default': OutputNetwork(nodes=out)}, converters={}) + self.result = Result( + networks={"default": OutputNetwork(nodes=out)}, converters={} + ) def test_build_consumption(self): # Expected - exp = pd.DataFrame(data={'cost': [10 ** 3] * 18, - 'asked': [120, 12, 12, 12, 120, 120, 130, 13, 13, 13, 130, 130, 120, 12, 12, 12, 120, 120], - 'given': [20, 2, 2, 2, 20, 20, 30, 3, 3, 3, 30, 30, 20, 2, 2, 2, 20, 20], - 'name': ['load'] * 6 + ['car'] * 6 + ['load'] * 6, - 'node': ['a'] * 12 + ['b'] * 6, - 'network': ['default'] * 18, - 't': [0, 1, 2] * 6, - 'scn': [0, 0, 0, 1, 1, 1] * 3}, dtype=float) + exp = pd.DataFrame( + data={ + "cost": [10 ** 3] * 18, + "asked": [ + 120, + 12, + 12, + 12, + 120, + 120, + 130, + 13, + 13, + 13, + 130, + 130, + 120, + 12, + 12, + 12, + 120, + 120, + ], + "given": [ + 20, + 2, + 2, + 2, + 20, + 20, + 30, + 3, + 3, + 3, + 30, + 30, + 20, + 2, + 2, + 2, + 20, + 20, + ], + "name": ["load"] * 6 + ["car"] * 6 + ["load"] * 6, + "node": ["a"] * 12 + ["b"] * 6, + "network": ["default"] * 18, + "t": [0, 1, 2] * 6, + "scn": [0, 0, 0, 1, 1, 1] * 3, + }, + dtype=float, + ) cons = ResultAnalyzer._build_consumption(self.study, self.result) @@ -92,55 +171,100 @@ def test_build_consumption(self): def test_aggregate_cons(self): # Expected - index = pd.Index(data=[0, 1, 2], dtype=float, name='t') - exp_cons = pd.DataFrame(data={'asked': [120, 12, 12], - 'cost': [10 ** 3] * 3, - 'given': [20, 2, 2]}, dtype=float, index=index) + index = pd.Index(data=[0, 1, 2], dtype=float, name="t") + exp_cons = pd.DataFrame( + data={"asked": [120, 12, 12], "cost": [10 ** 3] * 3, "given": [20, 2, 2]}, + dtype=float, + index=index, + ) # Test agg = ResultAnalyzer(study=self.study, result=self.result) - cons = agg.network().scn(0).node('a').consumption('load').time() + cons = agg.network().scn(0).node("a").consumption("load").time() pd.testing.assert_frame_equal(exp_cons, cons) def test_get_elements_inside(self): agg = ResultAnalyzer(study=self.study, result=self.result) - np.testing.assert_array_equal((2, 0, 0, 0, 0, 0), agg.get_elements_inside('a')) - np.testing.assert_array_equal((1, 0, 0, 0, 0, 0), agg.get_elements_inside('b')) + np.testing.assert_array_equal((2, 0, 0, 0, 0, 0), agg.get_elements_inside("a")) + np.testing.assert_array_equal((1, 0, 0, 0, 0, 0), agg.get_elements_inside("b")) class TestProductionAnalyzer(unittest.TestCase): def setUp(self) -> None: - self.study = Study(horizon=3, nb_scn=2)\ - .network()\ - .node('a')\ - .production(cost=10, quantity=[[130, 13, 13], [13, 130, 130]], name='prod')\ - .node('b')\ - .production(cost=20, quantity=[[110, 11, 11], [11, 110, 110]], name='prod')\ - .production(cost=20, quantity=[[120, 12, 12], [12, 120, 120]], name='nuclear') \ + self.study = ( + Study(horizon=3, nb_scn=2) + .network() + .node("a") + .production(cost=10, quantity=[[130, 13, 13], [13, 130, 130]], name="prod") + .node("b") + .production(cost=20, quantity=[[110, 11, 11], [11, 110, 110]], name="prod") + .production( + cost=20, quantity=[[120, 12, 12], [12, 120, 120]], name="nuclear" + ) .build() + ) out = { - 'a': OutputNode(productions=[OutputProduction(quantity=[[30, 3, 3], [3, 30, 30]], name='prod')], - consumptions=[], storages=[], links=[]), - - 'b': OutputNode(productions=[OutputProduction(quantity=[[10, 1, 1], [1, 10, 10]], name='prod'), - OutputProduction(quantity=[[20, 2, 2], [2, 20, 20]], name='nuclear')], - consumptions=[], storages=[], links=[]) + "a": OutputNode( + productions=[ + OutputProduction(quantity=[[30, 3, 3], [3, 30, 30]], name="prod") + ], + consumptions=[], + storages=[], + links=[], + ), + "b": OutputNode( + productions=[ + OutputProduction(quantity=[[10, 1, 1], [1, 10, 10]], name="prod"), + OutputProduction( + quantity=[[20, 2, 2], [2, 20, 20]], name="nuclear" + ), + ], + consumptions=[], + storages=[], + links=[], + ), } - self.result = Result(networks={'default': OutputNetwork(nodes=out)}, converters={}) + self.result = Result( + networks={"default": OutputNetwork(nodes=out)}, converters={} + ) def test_build_production(self): # Expected - exp = pd.DataFrame(data={'cost': [10] * 6 + [20] * 12, - 'avail': [130, 13, 13, 13, 130, 130, 110, 11, 11, 11, 110, 110, 120, 12, 12, 12, 120, 120], - 'used': [30, 3, 3, 3, 30, 30, 10, 1, 1, 1, 10, 10, 20, 2, 2, 2, 20, 20], - 'name': ['prod'] * 12 + ['nuclear'] * 6, - 'node': ['a'] * 6 + ['b'] * 12, - 'network': ['default'] * 18, - 't': [0, 1, 2] * 6, - 'scn': [0, 0, 0, 1, 1, 1] * 3}, dtype=float) + exp = pd.DataFrame( + data={ + "cost": [10] * 6 + [20] * 12, + "avail": [ + 130, + 13, + 13, + 13, + 130, + 130, + 110, + 11, + 11, + 11, + 110, + 110, + 120, + 12, + 12, + 12, + 120, + 120, + ], + "used": [30, 3, 3, 3, 30, 30, 10, 1, 1, 1, 10, 10, 20, 2, 2, 2, 20, 20], + "name": ["prod"] * 12 + ["nuclear"] * 6, + "node": ["a"] * 6 + ["b"] * 12, + "network": ["default"] * 18, + "t": [0, 1, 2] * 6, + "scn": [0, 0, 0, 1, 1, 1] * 3, + }, + dtype=float, + ) prod = ResultAnalyzer._build_production(self.study, self.result) @@ -148,118 +272,172 @@ def test_build_production(self): def test_aggregate_prod(self): # Expected - index = pd.MultiIndex.from_tuples((('a', 'prod', 0.0), ('a', 'prod', 1.0), ('a', 'prod', 2,0), - ('b', 'prod', 0.0), ('b', 'prod', 1.0), ('b', 'prod', 2,0)), - names=['node', 'name', 't'], ) - exp_cons = pd.DataFrame(data={'avail': [130, 13, 13, 110, 11, 11], - 'cost': [10, 10, 10, 20, 20, 20], - 'used': [30, 3, 3, 10, 1, 1]}, dtype=float, index=index) + index = pd.MultiIndex.from_tuples( + ( + ("a", "prod", 0.0), + ("a", "prod", 1.0), + ("a", "prod", 2, 0), + ("b", "prod", 0.0), + ("b", "prod", 1.0), + ("b", "prod", 2, 0), + ), + names=["node", "name", "t"], + ) + exp_cons = pd.DataFrame( + data={ + "avail": [130, 13, 13, 110, 11, 11], + "cost": [10, 10, 10, 20, 20, 20], + "used": [30, 3, 3, 10, 1, 1], + }, + dtype=float, + index=index, + ) # Test agg = ResultAnalyzer(study=self.study, result=self.result) - cons = agg.network().scn(0).node(['a', 'b']).production('prod').time() + cons = agg.network().scn(0).node(["a", "b"]).production("prod").time() pd.testing.assert_frame_equal(exp_cons, cons) def test_get_elements_inside(self): agg = ResultAnalyzer(study=self.study, result=self.result) - np.testing.assert_array_equal((0, 1, 0, 0, 0, 0), agg.get_elements_inside('a')) - np.testing.assert_array_equal((0, 2, 0, 0, 0, 0), agg.get_elements_inside('b')) + np.testing.assert_array_equal((0, 1, 0, 0, 0, 0), agg.get_elements_inside("a")) + np.testing.assert_array_equal((0, 2, 0, 0, 0, 0), agg.get_elements_inside("b")) class TestStorageAnalyzer(unittest.TestCase): def setUp(self) -> None: - self.study = Study(horizon=3, nb_scn=2)\ - .network()\ - .node('b')\ - .storage(name='store', capacity=100, flow_in=10, flow_out=20, cost=1) \ + self.study = ( + Study(horizon=3, nb_scn=2) + .network() + .node("b") + .storage(name="store", capacity=100, flow_in=10, flow_out=20, cost=1) .build() + ) out = { - 'b': OutputNode(storages=[OutputStorage(name='store', capacity=[[10, 1, 1], [1, 10, 10]], - flow_out=[[20, 2, 2], [2, 20, 20]], - flow_in=[[30, 3, 3], [3, 30, 30]])], - consumptions=[], productions=[], links=[]) + "b": OutputNode( + storages=[ + OutputStorage( + name="store", + capacity=[[10, 1, 1], [1, 10, 10]], + flow_out=[[20, 2, 2], [2, 20, 20]], + flow_in=[[30, 3, 3], [3, 30, 30]], + ) + ], + consumptions=[], + productions=[], + links=[], + ) } - self.result = Result(networks={'default': OutputNetwork(nodes=out)}, converters={}) + self.result = Result( + networks={"default": OutputNetwork(nodes=out)}, converters={} + ) def test_build_storage(self): # Expected - exp = pd.DataFrame(data={'max_capacity': [100] * 6, - 'capacity': [10, 1, 1, 1, 10, 10], - 'max_flow_in': [10] * 6, - 'flow_in': [30, 3, 3, 3, 30, 30], - 'max_flow_out': [20] * 6, - 'flow_out': [20, 2, 2, 2, 20, 20], - 'cost': [1] * 6, - 'init_capacity': [0] * 6, - 'eff': [.99] * 6, - 'name': ['store'] * 6, - 'node': ['b'] * 6, - 'network': ['default'] * 6, - 't': [0, 1, 2] * 2, - 'scn': [0, 0, 0, 1, 1, 1]}, dtype=float) + exp = pd.DataFrame( + data={ + "max_capacity": [100] * 6, + "capacity": [10, 1, 1, 1, 10, 10], + "max_flow_in": [10] * 6, + "flow_in": [30, 3, 3, 3, 30, 30], + "max_flow_out": [20] * 6, + "flow_out": [20, 2, 2, 2, 20, 20], + "cost": [1] * 6, + "init_capacity": [0] * 6, + "eff": [0.99] * 6, + "name": ["store"] * 6, + "node": ["b"] * 6, + "network": ["default"] * 6, + "t": [0, 1, 2] * 2, + "scn": [0, 0, 0, 1, 1, 1], + }, + dtype=float, + ) stor = ResultAnalyzer._build_storage(self.study, self.result) pd.testing.assert_frame_equal(exp, stor, check_dtype=False) def test_aggregate_stor(self): # Expected - index = pd.MultiIndex.from_tuples((('b', 'store', 0), ('b', 'store', 1), ('b', 'store', 2)), - names=['node', 'name', 't'], ) - exp_stor = pd.DataFrame(data={'capacity': [10, 1, 1], - 'cost': [1, 1, 1], - 'eff': [.99] * 3, - 'flow_in': [30, 3, 3], - 'flow_out': [20, 2, 2], - 'init_capacity': [0] * 3, - 'max_capacity': [100] * 3, - 'max_flow_in': [10] * 3, - 'max_flow_out': [20] * 3}, index=index) + index = pd.MultiIndex.from_tuples( + (("b", "store", 0), ("b", "store", 1), ("b", "store", 2)), + names=["node", "name", "t"], + ) + exp_stor = pd.DataFrame( + data={ + "capacity": [10, 1, 1], + "cost": [1, 1, 1], + "eff": [0.99] * 3, + "flow_in": [30, 3, 3], + "flow_out": [20, 2, 2], + "init_capacity": [0] * 3, + "max_capacity": [100] * 3, + "max_flow_in": [10] * 3, + "max_flow_out": [20] * 3, + }, + index=index, + ) # Test agg = ResultAnalyzer(study=self.study, result=self.result) - stor = agg.network().scn(0).node().storage('store').time() + stor = agg.network().scn(0).node().storage("store").time() pd.testing.assert_frame_equal(exp_stor, stor, check_dtype=False) def test_get_elements_inside(self): agg = ResultAnalyzer(study=self.study, result=self.result) - np.testing.assert_array_equal((0, 0, 1, 0, 0, 0), agg.get_elements_inside('b')) + np.testing.assert_array_equal((0, 0, 1, 0, 0, 0), agg.get_elements_inside("b")) class TestLinkAnalyzer(unittest.TestCase): def setUp(self) -> None: - self.study = Study(horizon=3, nb_scn=2)\ - .network()\ - .node('a')\ - .node('b')\ - .node('c')\ - .link(src='a', dest='b', quantity=[[110, 11, 11], [11, 110, 110]], cost=2)\ - .link(src='a', dest='c', quantity=[[120, 12, 12], [12, 120, 120]], cost=2)\ + self.study = ( + Study(horizon=3, nb_scn=2) + .network() + .node("a") + .node("b") + .node("c") + .link(src="a", dest="b", quantity=[[110, 11, 11], [11, 110, 110]], cost=2) + .link(src="a", dest="c", quantity=[[120, 12, 12], [12, 120, 120]], cost=2) .build() + ) blank_node = OutputNode(consumptions=[], productions=[], storages=[], links=[]) out = { - 'a': OutputNode(consumptions=[], productions=[], storages=[], - links=[OutputLink(dest='b', quantity=[[10, 1, 1], [1, 10, 10]]), - OutputLink(dest='c', quantity=[[20, 2, 2], [2, 20, 20]])]), - - 'b': blank_node, 'c': blank_node + "a": OutputNode( + consumptions=[], + productions=[], + storages=[], + links=[ + OutputLink(dest="b", quantity=[[10, 1, 1], [1, 10, 10]]), + OutputLink(dest="c", quantity=[[20, 2, 2], [2, 20, 20]]), + ], + ), + "b": blank_node, + "c": blank_node, } - self.result = Result(networks={'default': OutputNetwork(nodes=out)}, converters={}) + self.result = Result( + networks={"default": OutputNetwork(nodes=out)}, converters={} + ) def test_build_link(self): # Expected - exp = pd.DataFrame(data={'cost': [2] * 12, - 'avail': [110, 11, 11, 11, 110, 110, 120, 12, 12, 12, 120, 120], - 'used': [10, 1, 1, 1, 10, 10, 20, 2, 2, 2, 20, 20], - 'node': ['a'] * 12, - 'dest': ['b'] * 6 + ['c'] * 6, - 'network': ['default'] * 12, - 't': [0, 1, 2] * 4, - 'scn': [0, 0, 0, 1, 1, 1] * 2}, dtype=float) + exp = pd.DataFrame( + data={ + "cost": [2] * 12, + "avail": [110, 11, 11, 11, 110, 110, 120, 12, 12, 12, 120, 120], + "used": [10, 1, 1, 1, 10, 10, 20, 2, 2, 2, 20, 20], + "node": ["a"] * 12, + "dest": ["b"] * 6 + ["c"] * 6, + "network": ["default"] * 12, + "t": [0, 1, 2] * 4, + "scn": [0, 0, 0, 1, 1, 1] * 2, + }, + dtype=float, + ) link = ResultAnalyzer._build_link(self.study, self.result) @@ -267,55 +445,81 @@ def test_build_link(self): def test_aggregate_link(self): # Expected - index = pd.MultiIndex.from_tuples((('b', 0.0), ('b', 1.0), ('b', 2,0), - ('c', 0.0), ('c', 1.0), ('c', 2,0)), - names=['dest', 't'], ) - exp_link = pd.DataFrame(data={'avail': [110, 11, 11, 120, 12, 12], - 'cost': [2, 2, 2, 2, 2, 2], - 'used': [10, 1, 1, 20, 2, 2]}, dtype=float, index=index) + index = pd.MultiIndex.from_tuples( + (("b", 0.0), ("b", 1.0), ("b", 2, 0), ("c", 0.0), ("c", 1.0), ("c", 2, 0)), + names=["dest", "t"], + ) + exp_link = pd.DataFrame( + data={ + "avail": [110, 11, 11, 120, 12, 12], + "cost": [2, 2, 2, 2, 2, 2], + "used": [10, 1, 1, 20, 2, 2], + }, + dtype=float, + index=index, + ) agg = ResultAnalyzer(study=self.study, result=self.result) - link = agg.network().scn(0).node('a').link(['b', 'c']).time() + link = agg.network().scn(0).node("a").link(["b", "c"]).time() pd.testing.assert_frame_equal(exp_link, link) def test_balance(self): agg = ResultAnalyzer(study=self.study, result=self.result) - np.testing.assert_array_equal([[30, 3, 3], [3, 30, 30]], agg.get_balance(node='a')) - np.testing.assert_array_equal([[-10, -1, -1], [-1, -10, -10]], agg.get_balance(node='b')) + np.testing.assert_array_equal( + [[30, 3, 3], [3, 30, 30]], agg.get_balance(node="a") + ) + np.testing.assert_array_equal( + [[-10, -1, -1], [-1, -10, -10]], agg.get_balance(node="b") + ) def test_get_elements_inside(self): agg = ResultAnalyzer(study=self.study, result=self.result) - np.testing.assert_array_equal((0, 0, 0, 2, 0, 0), agg.get_elements_inside('a')) + np.testing.assert_array_equal((0, 0, 0, 2, 0, 0), agg.get_elements_inside("a")) class TestConverterAnalyzer(unittest.TestCase): def setUp(self) -> None: - self.study = Study(horizon=3, nb_scn=2)\ - .network()\ - .node('a')\ - .to_converter(name='conv', ratio=2)\ - .network('elec').node('a')\ - .converter(name='conv', to_network='elec', to_node='a', max=10, cost=1)\ + self.study = ( + Study(horizon=3, nb_scn=2) + .network() + .node("a") + .to_converter(name="conv", ratio=2) + .network("elec") + .node("a") + .converter(name="conv", to_network="elec", to_node="a", max=10, cost=1) .build() + ) - conv = OutputConverter(name='conv', flow_src={('default', 'a'): [[10, 1, 1], [1, 10, 10]]}, flow_dest=[[20, 2, 2], [2, 20, 20]]) + conv = OutputConverter( + name="conv", + flow_src={("default", "a"): [[10, 1, 1], [1, 10, 10]]}, + flow_dest=[[20, 2, 2], [2, 20, 20]], + ) blank_node = OutputNode(consumptions=[], productions=[], storages=[], links=[]) - self.result = Result(networks={'default': OutputNetwork(nodes={'a': blank_node}), - 'elec': OutputNetwork(nodes={'a': blank_node})}, - converters={'conv': conv}) + self.result = Result( + networks={ + "default": OutputNetwork(nodes={"a": blank_node}), + "elec": OutputNetwork(nodes={"a": blank_node}), + }, + converters={"conv": conv}, + ) def test_build_dest_converter(self): # Expected - exp = pd.DataFrame(data={'name': ['conv'] * 6, - 'network': ['elec'] * 6, - 'node': ['a'] * 6, - 'flow': [20, 2, 2, 2, 20, 20], - 'cost': [1] * 6, - 'max': [10] * 6, - 't': [0, 1, 2] * 2, - 'scn': [0, 0, 0, 1, 1, 1]}) + exp = pd.DataFrame( + data={ + "name": ["conv"] * 6, + "network": ["elec"] * 6, + "node": ["a"] * 6, + "flow": [20, 2, 2, 2, 20, 20], + "cost": [1] * 6, + "max": [10] * 6, + "t": [0, 1, 2] * 2, + "scn": [0, 0, 0, 1, 1, 1], + } + ) conv = ResultAnalyzer._build_dest_converter(self.study, self.result) @@ -323,76 +527,85 @@ def test_build_dest_converter(self): def test_build_src_converter(self): # Expected - exp = pd.DataFrame(data={'name': ['conv'] * 6, - 'network': ['default'] * 6, - 'node': ['a'] * 6, - 'ratio': [2] * 6, - 'flow': [10, 1, 1, 1, 10, 10], - 'max': [5] * 6, - 't': [0, 1, 2] * 2, - 'scn': [0, 0, 0, 1, 1, 1]}) + exp = pd.DataFrame( + data={ + "name": ["conv"] * 6, + "network": ["default"] * 6, + "node": ["a"] * 6, + "ratio": [2] * 6, + "flow": [10, 1, 1, 1, 10, 10], + "max": [5] * 6, + "t": [0, 1, 2] * 2, + "scn": [0, 0, 0, 1, 1, 1], + } + ) conv = ResultAnalyzer._build_src_converter(self.study, self.result) pd.testing.assert_frame_equal(exp, conv, check_dtype=False) - def test_aggregate_to_conv(self): # Expected - exp_conv = pd.DataFrame(data={'flow': [10, 1, 1], - 'max': [5] * 3, - 'ratio': [2] * 3}, index=pd.Index([0, 1, 2], name='t')) + exp_conv = pd.DataFrame( + data={"flow": [10, 1, 1], "max": [5] * 3, "ratio": [2] * 3}, + index=pd.Index([0, 1, 2], name="t"), + ) agg = ResultAnalyzer(study=self.study, result=self.result) - conv = agg.network().scn(0).node('a').to_converter('conv').time() + conv = agg.network().scn(0).node("a").to_converter("conv").time() pd.testing.assert_frame_equal(exp_conv, conv, check_dtype=False) def test_aggregate_from_conv(self): # Expected - exp_conv = pd.DataFrame(data={'cost': [1] * 3, - 'flow': [20, 2, 2], - 'max': [10] * 3}, index=pd.Index([0, 1, 2], name='t')) + exp_conv = pd.DataFrame( + data={"cost": [1] * 3, "flow": [20, 2, 2], "max": [10] * 3}, + index=pd.Index([0, 1, 2], name="t"), + ) agg = ResultAnalyzer(study=self.study, result=self.result) - conv = agg.network('elec').scn(0).node('a').from_converter('conv').time() + conv = agg.network("elec").scn(0).node("a").from_converter("conv").time() pd.testing.assert_frame_equal(exp_conv, conv, check_dtype=False) def test_get_elements_inside(self): agg = ResultAnalyzer(study=self.study, result=self.result) - np.testing.assert_array_equal((0, 0, 0, 0, 1, 0), agg.get_elements_inside('a')) - np.testing.assert_array_equal((0, 0, 0, 0, 0, 1), agg.get_elements_inside('a', network='elec')) + np.testing.assert_array_equal((0, 0, 0, 0, 1, 0), agg.get_elements_inside("a")) + np.testing.assert_array_equal( + (0, 0, 0, 0, 0, 1), agg.get_elements_inside("a", network="elec") + ) class TestAnalyzer(unittest.TestCase): def setUp(self) -> None: - self.study = Study(horizon=1)\ - .network()\ - .node('a')\ - .consumption(cost=10 ** 3, quantity=100, name='car')\ - .production(cost=10, quantity=70, name='prod')\ - .node('b')\ - .production(cost=20, quantity=70, name='nuclear') \ - .storage(name='store', capacity=100, flow_in=10, flow_out=20, cost=-1) \ - .to_converter(name='conv', ratio=2) \ - .link(src='b', dest='a', quantity=110, cost=2)\ - .network('elec')\ - .node('a')\ - .consumption(cost=10 ** 3, quantity=20, name='load')\ - .converter(name='conv', to_network='elec', to_node='a', max=10, cost=1)\ + self.study = ( + Study(horizon=1) + .network() + .node("a") + .consumption(cost=10 ** 3, quantity=100, name="car") + .production(cost=10, quantity=70, name="prod") + .node("b") + .production(cost=20, quantity=70, name="nuclear") + .storage(name="store", capacity=100, flow_in=10, flow_out=20, cost=-1) + .to_converter(name="conv", ratio=2) + .link(src="b", dest="a", quantity=110, cost=2) + .network("elec") + .node("a") + .consumption(cost=10 ** 3, quantity=20, name="load") + .converter(name="conv", to_network="elec", to_node="a", max=10, cost=1) .build() + ) optim = LPOptimizer() self.result = optim.solve(self.study) def test_cost(self): agg = ResultAnalyzer(study=self.study, result=self.result) - np.testing.assert_array_equal(700, agg.get_cost(node='a')) - np.testing.assert_array_equal(760, agg.get_cost(node='b')) - np.testing.assert_array_equal(10010, agg.get_cost(node='a', network='elec')) + np.testing.assert_array_equal(700, agg.get_cost(node="a")) + np.testing.assert_array_equal(760, agg.get_cost(node="b")) + np.testing.assert_array_equal(10010, agg.get_cost(node="a", network="elec")) def test_rac(self): agg = ResultAnalyzer(study=self.study, result=self.result) np.testing.assert_array_equal(35, agg.get_rac()) - np.testing.assert_array_equal(-10, agg.get_rac(network='elec')) + np.testing.assert_array_equal(-10, agg.get_rac(network="elec")) diff --git a/tests/optimizer/__init__.py b/tests/optimizer/__init__.py index 84711aa..f76a769 100644 --- a/tests/optimizer/__init__.py +++ b/tests/optimizer/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/tests/optimizer/domain/__init__.py b/tests/optimizer/domain/__init__.py index 84711aa..f76a769 100644 --- a/tests/optimizer/domain/__init__.py +++ b/tests/optimizer/domain/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/tests/optimizer/domain/test_input.py b/tests/optimizer/domain/test_input.py index 6062f78..13022d6 100644 --- a/tests/optimizer/domain/test_input.py +++ b/tests/optimizer/domain/test_input.py @@ -7,188 +7,273 @@ import json import unittest -from hadar.optimizer.domain.input import Study, Consumption, Production, Link, Storage, Converter +from hadar.optimizer.domain.input import ( + Study, + Consumption, + Production, + Link, + Storage, + Converter, +) from hadar.optimizer.domain.numeric import NumericalValueFactory class TestStudy(unittest.TestCase): def setUp(self) -> None: - self.study = Study(horizon=1) \ - .network() \ - .node('a') \ - .consumption(name='load', cost=20, quantity=10) \ - .production(name='nuclear', cost=20, quantity=10) \ - .to_converter(name='converter', ratio=1)\ - .node('b') \ - .link(src='b', dest='a', cost=20, quantity=10) \ - .network('gas')\ - .node('b')\ - .production(name='nuclear', cost=20, quantity=10)\ - .storage(name='store', capacity=100, flow_in=10, flow_out=10, cost=1, init_capacity=4, eff=0.1)\ - .node('a')\ - .consumption(name='load', cost=20, quantity=10)\ - .link(src='b', dest='a', cost=20, quantity=10) \ - .converter(name='converter', to_network='gas', to_node='b', cost=10, max=10) \ + self.study = ( + Study(horizon=1) + .network() + .node("a") + .consumption(name="load", cost=20, quantity=10) + .production(name="nuclear", cost=20, quantity=10) + .to_converter(name="converter", ratio=1) + .node("b") + .link(src="b", dest="a", cost=20, quantity=10) + .network("gas") + .node("b") + .production(name="nuclear", cost=20, quantity=10) + .storage( + name="store", + capacity=100, + flow_in=10, + flow_out=10, + cost=1, + init_capacity=4, + eff=0.1, + ) + .node("a") + .consumption(name="load", cost=20, quantity=10) + .link(src="b", dest="a", cost=20, quantity=10) + .converter(name="converter", to_network="gas", to_node="b", cost=10, max=10) .build() + ) - self.factory = NumericalValueFactory(horizon=self.study.horizon, nb_scn=self.study.nb_scn) + self.factory = NumericalValueFactory( + horizon=self.study.horizon, nb_scn=self.study.nb_scn + ) def test_create_study(self): - c = Consumption(name='load', cost=self.factory.create(20), quantity=self.factory.create(10)) - p = Production(name='nuclear', cost=self.factory.create(20), quantity=self.factory.create(10)) - s = Storage(name='store', capacity=self.factory.create(100), flow_in=self.factory.create(10), - flow_out=self.factory.create(10), cost=self.factory.create(1), init_capacity=4, eff=self.factory.create(0.1)) - l = Link(dest='a', cost=self.factory.create(20), quantity=self.factory.create(10)) - v = Converter(name='converter', src_ratios={('default', 'a'): self.factory.create(1)}, dest_network='gas', - dest_node='b', cost=self.factory.create(10), max=self.factory.create(10)) - - self.assertEqual(c, self.study.networks['default'].nodes['a'].consumptions[0]) - self.assertEqual(p, self.study.networks['default'].nodes['a'].productions[0]) - self.assertEqual(l, self.study.networks['default'].nodes['b'].links[0]) - - self.assertEqual(c, self.study.networks['gas'].nodes['a'].consumptions[0]) - self.assertEqual(p, self.study.networks['gas'].nodes['b'].productions[0]) - self.assertEqual(s, self.study.networks['gas'].nodes['b'].storages[0]) - self.assertEqual(l, self.study.networks['gas'].nodes['b'].links[0]) - - self.assertEqual(v, self.study.converters['converter']) + c = Consumption( + name="load", cost=self.factory.create(20), quantity=self.factory.create(10) + ) + p = Production( + name="nuclear", + cost=self.factory.create(20), + quantity=self.factory.create(10), + ) + s = Storage( + name="store", + capacity=self.factory.create(100), + flow_in=self.factory.create(10), + flow_out=self.factory.create(10), + cost=self.factory.create(1), + init_capacity=4, + eff=self.factory.create(0.1), + ) + l = Link( + dest="a", cost=self.factory.create(20), quantity=self.factory.create(10) + ) + v = Converter( + name="converter", + src_ratios={("default", "a"): self.factory.create(1)}, + dest_network="gas", + dest_node="b", + cost=self.factory.create(10), + max=self.factory.create(10), + ) + + self.assertEqual(c, self.study.networks["default"].nodes["a"].consumptions[0]) + self.assertEqual(p, self.study.networks["default"].nodes["a"].productions[0]) + self.assertEqual(l, self.study.networks["default"].nodes["b"].links[0]) + + self.assertEqual(c, self.study.networks["gas"].nodes["a"].consumptions[0]) + self.assertEqual(p, self.study.networks["gas"].nodes["b"].productions[0]) + self.assertEqual(s, self.study.networks["gas"].nodes["b"].storages[0]) + self.assertEqual(l, self.study.networks["gas"].nodes["b"].links[0]) + + self.assertEqual(v, self.study.converters["converter"]) self.assertEqual(1, self.study.horizon) def test_wrong_production_quantity(self): def test(): - study = Study(horizon=1) \ - .network().node('fr').production(name='solar', cost=1, quantity=-10).build() + study = ( + Study(horizon=1) + .network() + .node("fr") + .production(name="solar", cost=1, quantity=-10) + .build() + ) self.assertRaises(ValueError, test) def test_wrong_production_name(self): def test(): - study = Study(horizon=1) \ - .network()\ - .node('fr')\ - .production(name='solar', cost=1, quantity=-10)\ - .production(name='solar', cost=1, quantity=-10)\ + study = ( + Study(horizon=1) + .network() + .node("fr") + .production(name="solar", cost=1, quantity=-10) + .production(name="solar", cost=1, quantity=-10) .build() + ) self.assertRaises(ValueError, test) def test_wrong_consumption_quantity(self): def test(): - study = Study(horizon=1) \ - .network().node('fr').consumption(name='load', cost=1, quantity=-10).build() + study = ( + Study(horizon=1) + .network() + .node("fr") + .consumption(name="load", cost=1, quantity=-10) + .build() + ) self.assertRaises(ValueError, test) def test_wrong_consumption_name(self): def test(): - study = Study(horizon=1) \ - .network()\ - .node('fr')\ - .consumption(name='load', cost=1, quantity=-10)\ - .consumption(name='load', cost=1, quantity=-10)\ + study = ( + Study(horizon=1) + .network() + .node("fr") + .consumption(name="load", cost=1, quantity=-10) + .consumption(name="load", cost=1, quantity=-10) .build() + ) def test_wrong_storage_flow(self): def test_in(): - study = Study(horizon=1)\ - .network().node('fr')\ - .storage(name='store', capacity=1, flow_in=-1, flow_out=1)\ + study = ( + Study(horizon=1) + .network() + .node("fr") + .storage(name="store", capacity=1, flow_in=-1, flow_out=1) .build() + ) def test_out(): - study = Study(horizon=1)\ - .network().node('fr')\ - .storage(name='store', capacity=1, flow_in=1, flow_out=-1)\ + study = ( + Study(horizon=1) + .network() + .node("fr") + .storage(name="store", capacity=1, flow_in=1, flow_out=-1) .build() + ) + self.assertRaises(ValueError, test_in) self.assertRaises(ValueError, test_out) def test_wrong_storage_capacity(self): def test_capacity(): - study = Study(horizon=1)\ - .network().node('fr')\ - .storage(name='store', capacity=-1, flow_in=1, flow_out=1)\ + study = ( + Study(horizon=1) + .network() + .node("fr") + .storage(name="store", capacity=-1, flow_in=1, flow_out=1) .build() + ) def test_init_capacity(): - study = Study(horizon=1)\ - .network().node('fr')\ - .storage(name='store', capacity=1, flow_in=1, flow_out=1, init_capacity=-1)\ + study = ( + Study(horizon=1) + .network() + .node("fr") + .storage( + name="store", capacity=1, flow_in=1, flow_out=1, init_capacity=-1 + ) .build() + ) + self.assertRaises(ValueError, test_capacity) self.assertRaises(ValueError, test_init_capacity) def test_wrong_storage_eff(self): def test(): - study = Study(horizon=1)\ - .network().node('fr')\ - .storage(name='store', capacity=1, flow_in=1, flow_out=1, eff=-1)\ + study = ( + Study(horizon=1) + .network() + .node("fr") + .storage(name="store", capacity=1, flow_in=1, flow_out=1, eff=-1) .build() + ) self.assertRaises(ValueError, test) def test_wrong_link_quantity(self): def test(): - study = Study(horizon=1) \ - .network()\ - .node('fr')\ - .node('be')\ - .link(src='fr', dest='be', cost=10, quantity=-10)\ + study = ( + Study(horizon=1) + .network() + .node("fr") + .node("be") + .link(src="fr", dest="be", cost=10, quantity=-10) .build() + ) self.assertRaises(ValueError, test) def test_wrong_link_dest_not_node(self): def test(): - study = Study(horizon=1) \ - .network() \ - .node('fr') \ - .node('be') \ - .link(src='fr', dest='it', cost=10, quantity=10) \ + study = ( + Study(horizon=1) + .network() + .node("fr") + .node("be") + .link(src="fr", dest="it", cost=10, quantity=10) .build() + ) self.assertRaises(ValueError, test) def test_wrong_link_dest_not_unique(self): def test(): - study = Study(horizon=1) \ - .network() \ - .node('fr') \ - .node('be') \ - .link(src='fr', dest='be', cost=10, quantity=10) \ - .link(src='fr', dest='be', cost=10, quantity=10) \ + study = ( + Study(horizon=1) + .network() + .node("fr") + .node("be") + .link(src="fr", dest="be", cost=10, quantity=10) + .link(src="fr", dest="be", cost=10, quantity=10) .build() + ) self.assertRaises(ValueError, test) def test_wrong_converter_dest(self): def test_network(): - study = Study(horizon=1)\ - .network('elec')\ - .node('a')\ - .converter(name='conv', to_network='gas', to_node='a', max=1)\ + study = ( + Study(horizon=1) + .network("elec") + .node("a") + .converter(name="conv", to_network="gas", to_node="a", max=1) .build() + ) def test_node(): - study = Study(horizon=1)\ - .network('gas')\ - .node('a')\ - .converter(name='conv', to_network='gas', to_node='b', max=1)\ + study = ( + Study(horizon=1) + .network("gas") + .node("a") + .converter(name="conv", to_network="gas", to_node="b", max=1) .build() + ) self.assertRaises(ValueError, test_network) self.assertRaises(ValueError, test_node) def test_wrong_converter_src(self): def test(): - study = Study(horizon=1)\ - .network()\ - .node('a')\ - .to_converter(name='conv', ratio=1)\ - .to_converter(name='conv', ratio=2)\ - .converter(name='conv', to_node='', to_network='', max=1)\ + study = ( + Study(horizon=1) + .network() + .node("a") + .to_converter(name="conv", ratio=1) + .to_converter(name="conv", ratio=2) + .converter(name="conv", to_node="", to_network="", max=1) .build() + ) self.assertRaises(ValueError, test) @@ -197,4 +282,4 @@ def test_serialization(self): j = json.dumps(d) s = json.loads(j) s = Study.from_json(s) - self.assertEqual(self.study, s) \ No newline at end of file + self.assertEqual(self.study, s) diff --git a/tests/optimizer/domain/test_numeric.py b/tests/optimizer/domain/test_numeric.py index f7c7e45..4160636 100644 --- a/tests/optimizer/domain/test_numeric.py +++ b/tests/optimizer/domain/test_numeric.py @@ -8,7 +8,13 @@ import unittest import numpy as np -from hadar.optimizer.domain.numeric import NumericalValueFactory, ScalarNumericalValue, MatrixNumericalValue, RowNumericValue, ColumnNumericValue +from hadar.optimizer.domain.numeric import ( + NumericalValueFactory, + ScalarNumericalValue, + MatrixNumericalValue, + RowNumericValue, + ColumnNumericValue, +) class TestNumericalValue(unittest.TestCase): @@ -49,4 +55,6 @@ def test_column(self): self.assertEqual(2, v[2, 3]) self.assertRaises(IndexError, lambda: v[3, 1]) self.assertRaises(IndexError, lambda: v[1, 5]) - np.testing.assert_array_equal([0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2], v.flatten()) \ No newline at end of file + np.testing.assert_array_equal( + [0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2], v.flatten() + ) diff --git a/tests/optimizer/domain/test_output.py b/tests/optimizer/domain/test_output.py index b93316b..27f8a1b 100644 --- a/tests/optimizer/domain/test_output.py +++ b/tests/optimizer/domain/test_output.py @@ -12,13 +12,35 @@ class TestResult(unittest.TestCase): def test_json(self): - result = Result(networks={'default': OutputNetwork(nodes={'a': OutputNode( - consumptions=[OutputConsumption(name='load', quantity=[[1]])], - productions=[OutputProduction(name='prod', quantity=[[1]])], - links=[OutputLink(dest='b', quantity=[[1]])], - storages=[OutputStorage(name='cell', capacity=[[1]], flow_in=[[1]], flow_out=[[1]])])})}, - converters={'cell': OutputConverter(name='conv', flow_src={('elec', 'b'): [[1]]}, flow_dest=[[1]])}) + result = Result( + networks={ + "default": OutputNetwork( + nodes={ + "a": OutputNode( + consumptions=[ + OutputConsumption(name="load", quantity=[[1]]) + ], + productions=[OutputProduction(name="prod", quantity=[[1]])], + links=[OutputLink(dest="b", quantity=[[1]])], + storages=[ + OutputStorage( + name="cell", + capacity=[[1]], + flow_in=[[1]], + flow_out=[[1]], + ) + ], + ) + } + ) + }, + converters={ + "cell": OutputConverter( + name="conv", flow_src={("elec", "b"): [[1]]}, flow_dest=[[1]] + ) + }, + ) string = json.dumps(result.to_json()) r = Result.from_json(json.loads(string)) - self.assertEqual(result, r) \ No newline at end of file + self.assertEqual(result, r) diff --git a/tests/optimizer/it/__init__.py b/tests/optimizer/it/__init__.py index 84711aa..f76a769 100644 --- a/tests/optimizer/it/__init__.py +++ b/tests/optimizer/it/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/tests/optimizer/it/test_optimizer.py b/tests/optimizer/it/test_optimizer.py index d68afc5..5d8f79a 100644 --- a/tests/optimizer/it/test_optimizer.py +++ b/tests/optimizer/it/test_optimizer.py @@ -8,12 +8,20 @@ import unittest import hadar as hd -from hadar.optimizer.domain.output import OutputLink, OutputNode, OutputNetwork, OutputProduction, OutputConsumption, OutputStorage, OutputConverter, Result +from hadar.optimizer.domain.output import ( + OutputLink, + OutputNode, + OutputNetwork, + OutputProduction, + OutputConsumption, + OutputStorage, + OutputConverter, + Result, +) from tests.utils import assert_result class TestOptimizer(unittest.TestCase): - def setUp(self) -> None: self.optimizer = hd.LPOptimizer() @@ -35,27 +43,37 @@ def test_merit_order(self): | gas: 5 | :return: """ - study = hd.Study(horizon=3, nb_scn=2)\ - .network()\ - .node('a')\ - .consumption(name='load', cost=10 ** 6, quantity=[[30, 6, 6], [6, 30, 30]])\ - .production(name='nuclear', cost=20, quantity=[[15, 3, 3], [3, 15, 15]])\ - .production(name='solar', cost=10, quantity=[[10, 2, 2], [2, 10, 10]])\ - .production(name='oil', cost=30, quantity=[[10, 2, 2], [2, 10, 10]])\ + study = ( + hd.Study(horizon=3, nb_scn=2) + .network() + .node("a") + .consumption(name="load", cost=10 ** 6, quantity=[[30, 6, 6], [6, 30, 30]]) + .production(name="nuclear", cost=20, quantity=[[15, 3, 3], [3, 15, 15]]) + .production(name="solar", cost=10, quantity=[[10, 2, 2], [2, 10, 10]]) + .production(name="oil", cost=30, quantity=[[10, 2, 2], [2, 10, 10]]) .build() + ) nodes_expected = dict() - nodes_expected['a'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[30, 6, 6], [6, 30, 30]], name='load')], + nodes_expected["a"] = OutputNode( + consumptions=[ + OutputConsumption(quantity=[[30, 6, 6], [6, 30, 30]], name="load") + ], productions=[ - OutputProduction(name='nuclear', quantity=[[15, 3, 3], [3, 15, 15]]), - OutputProduction(name='solar', quantity=[[10, 2, 2], [2, 10, 10]]), - OutputProduction(name='oil', quantity=[[5, 1, 1], [1, 5, 5]])], + OutputProduction(name="nuclear", quantity=[[15, 3, 3], [3, 15, 15]]), + OutputProduction(name="solar", quantity=[[10, 2, 2], [2, 10, 10]]), + OutputProduction(name="oil", quantity=[[5, 1, 1], [1, 5, 5]]), + ], storages=[], - links=[]) + links=[], + ) res = self.optimizer.solve(study) - assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result( + self, + Result(networks={"default": OutputNetwork(nodes_expected)}, converters={}), + res, + ) def test_exchange_two_nodes(self): """ @@ -73,32 +91,40 @@ def test_exchange_two_nodes(self): :return: """ # Input - study = hd.Study(horizon=2)\ - .network()\ - .node('a')\ - .consumption(cost=10 ** 6, quantity=[20, 200], name='load')\ - .production(cost=10, quantity=[30, 300], name='prod')\ - .node('b')\ - .consumption(cost=10 ** 6, quantity=[20, 200], name='load')\ - .production(cost=20, quantity=[10, 100], name='prod')\ - .link(src='a', dest='b', quantity=[10, 100], cost=2)\ + study = ( + hd.Study(horizon=2) + .network() + .node("a") + .consumption(cost=10 ** 6, quantity=[20, 200], name="load") + .production(cost=10, quantity=[30, 300], name="prod") + .node("b") + .consumption(cost=10 ** 6, quantity=[20, 200], name="load") + .production(cost=20, quantity=[10, 100], name="prod") + .link(src="a", dest="b", quantity=[10, 100], cost=2) .build() + ) nodes_expected = {} - nodes_expected['a'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[20, 200]], name='load')], - productions=[OutputProduction(quantity=[[30, 300]], name='prod')], + nodes_expected["a"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[20, 200]], name="load")], + productions=[OutputProduction(quantity=[[30, 300]], name="prod")], storages=[], - links=[OutputLink(dest='b', quantity=[[10, 100]])]) + links=[OutputLink(dest="b", quantity=[[10, 100]])], + ) - nodes_expected['b'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[20, 200]], name='load')], - productions=[OutputProduction(quantity=[[10, 100]], name='prod')], + nodes_expected["b"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[20, 200]], name="load")], + productions=[OutputProduction(quantity=[[10, 100]], name="prod")], storages=[], - links=[]) + links=[], + ) res = self.optimizer.solve(study) - assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result( + self, + Result(networks={"default": OutputNetwork(nodes_expected)}, converters={}), + res, + ) def test_exchange_two_concurrent_nodes(self): """ @@ -121,44 +147,55 @@ def test_exchange_two_concurrent_nodes(self): | nuclear: 0 | :return: """ - study = hd.Study(horizon=1)\ - .network()\ - .node('a')\ - .consumption(cost=10 ** 6, quantity=10, name='load')\ - .production(cost=10, quantity=30, name='nuclear')\ - .node('b')\ - .consumption(cost=10 ** 6, quantity=10, name='load')\ - .production(cost=20, quantity=10, name='nuclear')\ - .node('c')\ - .consumption(cost=10 ** 6, quantity=10, name='load')\ - .production(cost=20, quantity=10, name='nuclear')\ - .link(src='a', dest='b', quantity=20, cost=2)\ - .link(src='a', dest='c', quantity=20, cost=2)\ + study = ( + hd.Study(horizon=1) + .network() + .node("a") + .consumption(cost=10 ** 6, quantity=10, name="load") + .production(cost=10, quantity=30, name="nuclear") + .node("b") + .consumption(cost=10 ** 6, quantity=10, name="load") + .production(cost=20, quantity=10, name="nuclear") + .node("c") + .consumption(cost=10 ** 6, quantity=10, name="load") + .production(cost=20, quantity=10, name="nuclear") + .link(src="a", dest="b", quantity=20, cost=2) + .link(src="a", dest="c", quantity=20, cost=2) .build() + ) nodes_expected = {} - nodes_expected['a'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[10]], name='load')], - productions=[OutputProduction(quantity=[[30]], name='nuclear')], + nodes_expected["a"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name="load")], + productions=[OutputProduction(quantity=[[30]], name="nuclear")], storages=[], - links=[OutputLink(dest='b', quantity=[[10]]), - OutputLink(dest='c', quantity=[[10]])]) - - nodes_expected['b'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[10]], name='load')], - productions=[OutputProduction(quantity=[[0]], name='nuclear')], + links=[ + OutputLink(dest="b", quantity=[[10]]), + OutputLink(dest="c", quantity=[[10]]), + ], + ) + + nodes_expected["b"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name="load")], + productions=[OutputProduction(quantity=[[0]], name="nuclear")], storages=[], - links=[]) + links=[], + ) - nodes_expected['c'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[10]], name='load')], - productions=[OutputProduction(quantity=[[0]], name='nuclear')], + nodes_expected["c"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name="load")], + productions=[OutputProduction(quantity=[[0]], name="nuclear")], storages=[], - links=[]) + links=[], + ) res = self.optimizer.solve(study) - assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result( + self, + Result(networks={"default": OutputNetwork(nodes_expected)}, converters={}), + res, + ) def test_exchange_link_saturation(self): """ @@ -173,35 +210,49 @@ def test_exchange_link_saturation(self): :return: """ - study = hd.Study(horizon=1)\ - .network()\ - .node('a').production(cost=10, quantity=[30], name='nuclear')\ - .node('b').consumption(cost=10 ** 6, quantity=[10], name='load')\ - .node('c').consumption(cost=10 ** 6, quantity=[20], name='load')\ - .link(src='a', dest='b', quantity=[20], cost=2)\ - .link(src='b', dest='c', quantity=[15], cost=2)\ + study = ( + hd.Study(horizon=1) + .network() + .node("a") + .production(cost=10, quantity=[30], name="nuclear") + .node("b") + .consumption(cost=10 ** 6, quantity=[10], name="load") + .node("c") + .consumption(cost=10 ** 6, quantity=[20], name="load") + .link(src="a", dest="b", quantity=[20], cost=2) + .link(src="b", dest="c", quantity=[15], cost=2) .build() + ) nodes_expected = {} - nodes_expected['a'] = OutputNode(productions=[OutputProduction(quantity=[[20]], name='nuclear')], - links=[OutputLink(dest='b', quantity=[[20]])], - storages=[], consumptions=[]) + nodes_expected["a"] = OutputNode( + productions=[OutputProduction(quantity=[[20]], name="nuclear")], + links=[OutputLink(dest="b", quantity=[[20]])], + storages=[], + consumptions=[], + ) - nodes_expected['b'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[10]], name='load')], - links=[OutputLink(dest='c', quantity=[[10]])], + nodes_expected["b"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name="load")], + links=[OutputLink(dest="c", quantity=[[10]])], storages=[], - productions=[]) + productions=[], + ) - nodes_expected['c'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[10]], name='load')], + nodes_expected["c"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name="load")], productions=[], storages=[], - links=[]) + links=[], + ) res = self.optimizer.solve(study) - assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result( + self, + Result(networks={"default": OutputNetwork(nodes_expected)}, converters={}), + res, + ) def test_consumer_cancel_exchange(self): """ @@ -219,44 +270,52 @@ def test_consumer_cancel_exchange(self): :return: """ - study = hd.Study(horizon=1)\ - .network()\ - .node('a')\ - .consumption(cost=10 ** 6, quantity=10, name='load')\ - .production(cost=10, quantity=20, name='nuclear')\ - .node('b')\ - .consumption(cost=10 ** 6, quantity=5, name='load')\ - .production(cost=20, quantity=15, name='nuclear')\ - .node('c')\ - .consumption(cost=10 ** 6, quantity=20, name='load')\ - .production(cost=10, quantity=10, name='nuclear')\ - .link(src='a', dest='b', quantity=20, cost=2)\ - .link(src='b', dest='c', quantity=20, cost=2)\ + study = ( + hd.Study(horizon=1) + .network() + .node("a") + .consumption(cost=10 ** 6, quantity=10, name="load") + .production(cost=10, quantity=20, name="nuclear") + .node("b") + .consumption(cost=10 ** 6, quantity=5, name="load") + .production(cost=20, quantity=15, name="nuclear") + .node("c") + .consumption(cost=10 ** 6, quantity=20, name="load") + .production(cost=10, quantity=10, name="nuclear") + .link(src="a", dest="b", quantity=20, cost=2) + .link(src="b", dest="c", quantity=20, cost=2) .build() + ) nodes_expected = {} - nodes_expected['a'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[10]], name='load')], - productions=[OutputProduction(quantity=[[20]], name='nuclear')], + nodes_expected["a"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name="load")], + productions=[OutputProduction(quantity=[[20]], name="nuclear")], storages=[], - links=[OutputLink(dest='b', quantity=[[10]])]) + links=[OutputLink(dest="b", quantity=[[10]])], + ) - nodes_expected['b'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[5]], name='load')], - productions=[OutputProduction(quantity=[[5]], name='nuclear')], + nodes_expected["b"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[5]], name="load")], + productions=[OutputProduction(quantity=[[5]], name="nuclear")], storages=[], - links=[OutputLink(dest='c', quantity=[[10]])]) + links=[OutputLink(dest="c", quantity=[[10]])], + ) - nodes_expected['c'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[20]], name='load')], - productions=[OutputProduction(quantity=[[10]], name='nuclear')], + nodes_expected["c"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[20]], name="load")], + productions=[OutputProduction(quantity=[[10]], name="nuclear")], storages=[], - links=[]) + links=[], + ) res = self.optimizer.solve(study) - assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) - + assert_result( + self, + Result(networks={"default": OutputNetwork(nodes_expected)}, converters={}), + res, + ) def test_many_links_on_node(self): """ @@ -285,38 +344,51 @@ def test_many_links_on_node(self): :return: """ - study = hd.Study(horizon=2)\ - .network()\ - .node('a')\ - .consumption(cost=10 ** 6, quantity=10, name='load')\ - .production(cost=80, quantity=20, name='gas')\ - .node('b')\ - .consumption(cost=10 ** 6, quantity=[15, 25], name='load')\ - .node('c')\ - .production(cost=50, quantity=30, name='nuclear')\ - .link(src='a', dest='b', quantity=20, cost=10)\ - .link(src='c', dest='a', quantity=20, cost=10)\ - .link(src='c', dest='b', quantity=15, cost=10)\ + study = ( + hd.Study(horizon=2) + .network() + .node("a") + .consumption(cost=10 ** 6, quantity=10, name="load") + .production(cost=80, quantity=20, name="gas") + .node("b") + .consumption(cost=10 ** 6, quantity=[15, 25], name="load") + .node("c") + .production(cost=50, quantity=30, name="nuclear") + .link(src="a", dest="b", quantity=20, cost=10) + .link(src="c", dest="a", quantity=20, cost=10) + .link(src="c", dest="b", quantity=15, cost=10) .build() - + ) nodes_expected = {} - nodes_expected['a'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[10, 10]], name='load')], - productions=[OutputProduction(quantity=[[0, 5]], name='gas')], - storages=[], links=[OutputLink(dest='b', quantity=[[0, 10]])]) + nodes_expected["a"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[10, 10]], name="load")], + productions=[OutputProduction(quantity=[[0, 5]], name="gas")], + storages=[], + links=[OutputLink(dest="b", quantity=[[0, 10]])], + ) - nodes_expected['b'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[15, 25]], name='load')], - storages=[], productions=[], links=[]) + nodes_expected["b"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[15, 25]], name="load")], + storages=[], + productions=[], + links=[], + ) - nodes_expected['c'] = OutputNode( - productions=[OutputProduction(quantity=[[25, 30]], name='nuclear')], - storages=[], links=[], consumptions=[]) + nodes_expected["c"] = OutputNode( + productions=[OutputProduction(quantity=[[25, 30]], name="nuclear")], + storages=[], + links=[], + consumptions=[], + ) res = self.optimizer.solve(study) - assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result( + self, + Result(networks={"default": OutputNetwork(nodes_expected)}, converters={}), + res, + ) def test_storage(self): """ @@ -327,63 +399,117 @@ def test_storage(self): :return: """ - study = hd.Study(horizon=4)\ - .network()\ - .node('a')\ - .production(name='nuclear', cost=20, quantity=[10, 10, 10, 0]) \ - .node('b')\ - .consumption(name='load', cost=10 ** 6, quantity=[20, 10, 0, 10]) \ - .storage(name='cell', capacity=30, flow_in=20, flow_out=20, - init_capacity=15, eff=.5)\ - .link(src='a', dest='b', cost=1, quantity=10)\ + study = ( + hd.Study(horizon=4) + .network() + .node("a") + .production(name="nuclear", cost=20, quantity=[10, 10, 10, 0]) + .node("b") + .consumption(name="load", cost=10 ** 6, quantity=[20, 10, 0, 10]) + .storage( + name="cell", + capacity=30, + flow_in=20, + flow_out=20, + init_capacity=15, + eff=0.5, + ) + .link(src="a", dest="b", cost=1, quantity=10) .build() + ) nodes_expected = dict() - nodes_expected['a'] = OutputNode( - productions=[OutputProduction(quantity=[[10, 10, 10, 0]], name='nuclear')], - storages=[], consumptions=[], links=[OutputLink(dest='b', quantity=[[10, 10, 10, 0]])]) - - nodes_expected['b'] = OutputNode( - consumptions=[OutputConsumption(quantity=[[20, 10, 0, 10]], name='load')], - storages=[OutputStorage(name='cell', capacity=[[5, 5, 10, 0]], - flow_in=[[0, 0, 10, 0]], flow_out=[[10, 0, 0, 10]])], - productions=[], links=[]) + nodes_expected["a"] = OutputNode( + productions=[OutputProduction(quantity=[[10, 10, 10, 0]], name="nuclear")], + storages=[], + consumptions=[], + links=[OutputLink(dest="b", quantity=[[10, 10, 10, 0]])], + ) + + nodes_expected["b"] = OutputNode( + consumptions=[OutputConsumption(quantity=[[20, 10, 0, 10]], name="load")], + storages=[ + OutputStorage( + name="cell", + capacity=[[5, 5, 10, 0]], + flow_in=[[0, 0, 10, 0]], + flow_out=[[10, 0, 0, 10]], + ) + ], + productions=[], + links=[], + ) res = self.optimizer.solve(study) - assert_result(self, Result(networks={'default': OutputNetwork(nodes_expected)}, converters={}), res) + assert_result( + self, + Result(networks={"default": OutputNetwork(nodes_expected)}, converters={}), + res, + ) def test_multi_energies(self): - study = hd.Study(horizon=1)\ - .network('elec')\ - .node('a')\ - .consumption(name='load', cost=10**6, quantity=10)\ - .network('gas')\ - .node('b')\ - .production(name='central', cost=10, quantity=50)\ - .to_converter(name='conv', ratio=0.8)\ - .network('coat')\ - .node('c')\ - .production(name='central', cost=10, quantity=50)\ - .to_converter(name='conv', ratio=0.5)\ - .converter(name='conv', to_network='elec', to_node='a', max=50)\ + study = ( + hd.Study(horizon=1) + .network("elec") + .node("a") + .consumption(name="load", cost=10 ** 6, quantity=10) + .network("gas") + .node("b") + .production(name="central", cost=10, quantity=50) + .to_converter(name="conv", ratio=0.8) + .network("coat") + .node("c") + .production(name="central", cost=10, quantity=50) + .to_converter(name="conv", ratio=0.5) + .converter(name="conv", to_network="elec", to_node="a", max=50) .build() + ) networks_expected = dict() - networks_expected['elec'] = OutputNetwork(nodes={'a': OutputNode( - consumptions=[OutputConsumption(quantity=[[10]], name='load')], - storages=[], productions=[], links=[])}) - - networks_expected['gas'] = OutputNetwork(nodes={'b': OutputNode( - productions=[OutputProduction(quantity=[[12.5]], name='central')], - storages=[], consumptions=[], links=[])}) - - networks_expected['coat'] = OutputNetwork(nodes={'c': OutputNode( - productions=[OutputProduction(quantity=[[20]], name='central')], - storages=[], consumptions=[], links=[])}) - - converter_expected = OutputConverter(name='conv', flow_src={('gas', 'b'): [[12.5]], ('coat', 'c'): [[20]]}, flow_dest=[[10]]) + networks_expected["elec"] = OutputNetwork( + nodes={ + "a": OutputNode( + consumptions=[OutputConsumption(quantity=[[10]], name="load")], + storages=[], + productions=[], + links=[], + ) + } + ) + + networks_expected["gas"] = OutputNetwork( + nodes={ + "b": OutputNode( + productions=[OutputProduction(quantity=[[12.5]], name="central")], + storages=[], + consumptions=[], + links=[], + ) + } + ) + + networks_expected["coat"] = OutputNetwork( + nodes={ + "c": OutputNode( + productions=[OutputProduction(quantity=[[20]], name="central")], + storages=[], + consumptions=[], + links=[], + ) + } + ) + + converter_expected = OutputConverter( + name="conv", + flow_src={("gas", "b"): [[12.5]], ("coat", "c"): [[20]]}, + flow_dest=[[10]], + ) res = self.optimizer.solve(study) - assert_result(self, Result(networks=networks_expected, converters={'conv': converter_expected}), res) \ No newline at end of file + assert_result( + self, + Result(networks=networks_expected, converters={"conv": converter_expected}), + res, + ) diff --git a/tests/optimizer/lp/__init__.py b/tests/optimizer/lp/__init__.py index 84711aa..f76a769 100644 --- a/tests/optimizer/lp/__init__.py +++ b/tests/optimizer/lp/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/tests/optimizer/lp/ortools_mock.py b/tests/optimizer/lp/ortools_mock.py index 49198ed..871272a 100644 --- a/tests/optimizer/lp/ortools_mock.py +++ b/tests/optimizer/lp/ortools_mock.py @@ -48,7 +48,7 @@ class MockSolver(Solver): def __init__(self): pass - def NumVar(self, min: float, max: float, name: str = ''): + def NumVar(self, min: float, max: float, name: str = ""): return MockNumVar(min, max, name) def Objective(self) -> MockObjective: @@ -64,4 +64,4 @@ def EnableOutput(self): pass def ExportModelAsLpFormat(self, toggle: bool): - return '' + return "" diff --git a/tests/optimizer/lp/test_mapper.py b/tests/optimizer/lp/test_mapper.py index 3427a25..ae7db85 100644 --- a/tests/optimizer/lp/test_mapper.py +++ b/tests/optimizer/lp/test_mapper.py @@ -8,10 +8,25 @@ import unittest from hadar.optimizer.domain.input import Study -from hadar.optimizer.lp.domain import LPLink, LPConsumption, LPProduction, LPNode, LPStorage, LPConverter +from hadar.optimizer.lp.domain import ( + LPLink, + LPConsumption, + LPProduction, + LPNode, + LPStorage, + LPConverter, +) from hadar.optimizer.lp.mapper import InputMapper, OutputMapper -from hadar.optimizer.domain.output import OutputConsumption, OutputLink, OutputNode, OutputProduction, Result, OutputNetwork, \ - OutputStorage, OutputConverter +from hadar.optimizer.domain.output import ( + OutputConsumption, + OutputLink, + OutputNode, + OutputProduction, + Result, + OutputNetwork, + OutputStorage, + OutputConverter, +) from tests.optimizer.lp.ortools_mock import MockSolver, MockNumVar from tests.utils import assert_result @@ -19,264 +34,531 @@ class TestInputMapper(unittest.TestCase): def test_map_consumption(self): # Input - study = Study(horizon=2, nb_scn=2) \ - .network()\ - .node('a')\ - .consumption(name='load', quantity=[[10, 1], [20, 2]], cost=[[.01, .1], [.02, .2]])\ + study = ( + Study(horizon=2, nb_scn=2) + .network() + .node("a") + .consumption( + name="load", + quantity=[[10, 1], [20, 2]], + cost=[[0.01, 0.1], [0.02, 0.2]], + ) .build() + ) s = MockSolver() mapper = InputMapper(solver=s, study=study) # Expected - suffix = 'inside network=default on node=a at t=0 for scn=0' - out_cons_0 = [LPConsumption(name='load', cost=.01, quantity=10, variable=MockNumVar(0, 10, 'lol=load %s' % suffix))] - out_node_0 = LPNode(consumptions=out_cons_0, productions=[], storages=[], links=[]) - - self.assertEqual(out_node_0, mapper.get_node_var(network='default', node='a', t=0, scn=0)) - - suffix = 'inside network=default on node=a at t=1 for scn=1' - out_cons_1 = [LPConsumption(name='load', cost=.2, quantity=2, variable=MockNumVar(0, 2, 'lol=load %s' % suffix))] - out_node_1 = LPNode(consumptions=out_cons_1, productions=[], storages=[], links=[]) - - self.assertEqual(out_node_1, mapper.get_node_var(network='default', node='a', t=1, scn=1)) + suffix = "inside network=default on node=a at t=0 for scn=0" + out_cons_0 = [ + LPConsumption( + name="load", + cost=0.01, + quantity=10, + variable=MockNumVar(0, 10, "lol=load %s" % suffix), + ) + ] + out_node_0 = LPNode( + consumptions=out_cons_0, productions=[], storages=[], links=[] + ) + + self.assertEqual( + out_node_0, mapper.get_node_var(network="default", node="a", t=0, scn=0) + ) + + suffix = "inside network=default on node=a at t=1 for scn=1" + out_cons_1 = [ + LPConsumption( + name="load", + cost=0.2, + quantity=2, + variable=MockNumVar(0, 2, "lol=load %s" % suffix), + ) + ] + out_node_1 = LPNode( + consumptions=out_cons_1, productions=[], storages=[], links=[] + ) + + self.assertEqual( + out_node_1, mapper.get_node_var(network="default", node="a", t=1, scn=1) + ) def test_map_production(self): # Input - study = Study(horizon=2, nb_scn=2) \ - .network() \ - .node('a') \ - .production(name='nuclear', quantity=[[12, 2], [21, 20]], cost=[[0.12, 0.2], [0.21, 0.02]]) \ + study = ( + Study(horizon=2, nb_scn=2) + .network() + .node("a") + .production( + name="nuclear", + quantity=[[12, 2], [21, 20]], + cost=[[0.12, 0.2], [0.21, 0.02]], + ) .build() + ) s = MockSolver() mapper = InputMapper(solver=s, study=study) # Expected - suffix = 'inside network=default on node=a at t=0 for scn=0' - out_prod_0 = [LPProduction(name='nuclear', cost=0.12, quantity=12, variable=MockNumVar(0, 12.0, 'prod=nuclear %s' % suffix))] - out_node_0 = LPNode(consumptions=[], productions=out_prod_0, storages=[], links=[]) - - self.assertEqual(out_node_0, mapper.get_node_var(network='default', node='a', t=0, scn=0)) - - suffix = 'inside network=default on node=a at t=1 for scn=1' - - out_prod_1 = [LPProduction(name='nuclear', cost=.02, quantity=20, variable=MockNumVar(0, 20.0, 'prod=nuclear %s' % suffix))] - out_node_1 = LPNode(consumptions=[], productions=out_prod_1, storages=[], links=[]) - - self.assertEqual(out_node_1, mapper.get_node_var(network='default', node='a', t=1, scn=1)) + suffix = "inside network=default on node=a at t=0 for scn=0" + out_prod_0 = [ + LPProduction( + name="nuclear", + cost=0.12, + quantity=12, + variable=MockNumVar(0, 12.0, "prod=nuclear %s" % suffix), + ) + ] + out_node_0 = LPNode( + consumptions=[], productions=out_prod_0, storages=[], links=[] + ) + + self.assertEqual( + out_node_0, mapper.get_node_var(network="default", node="a", t=0, scn=0) + ) + + suffix = "inside network=default on node=a at t=1 for scn=1" + + out_prod_1 = [ + LPProduction( + name="nuclear", + cost=0.02, + quantity=20, + variable=MockNumVar(0, 20.0, "prod=nuclear %s" % suffix), + ) + ] + out_node_1 = LPNode( + consumptions=[], productions=out_prod_1, storages=[], links=[] + ) + + self.assertEqual( + out_node_1, mapper.get_node_var(network="default", node="a", t=1, scn=1) + ) def test_map_storage(self): # Input - study = Study(horizon=2, nb_scn=2) \ - .network()\ - .node('a')\ - .storage(name='cell', capacity=10, flow_in=1, flow_out=1, cost=1, init_capacity=2, eff=.9) \ + study = ( + Study(horizon=2, nb_scn=2) + .network() + .node("a") + .storage( + name="cell", + capacity=10, + flow_in=1, + flow_out=1, + cost=1, + init_capacity=2, + eff=0.9, + ) .build() + ) s = MockSolver() mapper = InputMapper(solver=s, study=study) # Expected - suffix = 'inside network=default on node=a at t=0 for scn=0' - out_stor_0 = [LPStorage(name='cell', capacity=10, var_capacity=MockNumVar(0, 10, 'storage_capacity=cell %s' % suffix), - flow_in=1, var_flow_in=MockNumVar(0, 1, 'storage_flow_in=cell %s' % suffix), - flow_out=1, var_flow_out=MockNumVar(0, 1, 'storage_flow_out=cell %s' % suffix), - cost=1, init_capacity=2, eff=.9)] - out_node_0 = LPNode(consumptions=[], productions=[], storages=out_stor_0, links=[]) - - self.assertEqual(out_node_0, mapper.get_node_var(network='default', node='a', t=0, scn=0)) - - suffix = 'inside network=default on node=a at t=1 for scn=1' - out_stor_1 = [LPStorage(name='cell', capacity=10, var_capacity=MockNumVar(0, 10, 'storage_capacity=cell %s' % suffix), - flow_in=1, var_flow_in=MockNumVar(0, 1, 'storage_flow_in=cell %s' % suffix), - flow_out=1, var_flow_out=MockNumVar(0, 1, 'storage_flow_out=cell %s' % suffix), - cost=1, init_capacity=2, eff=.9)] - out_node_1 = LPNode(consumptions=[], productions=[], storages=out_stor_1, links=[]) - - self.assertEqual(out_node_1, mapper.get_node_var(network='default', node='a', t=1, scn=1)) + suffix = "inside network=default on node=a at t=0 for scn=0" + out_stor_0 = [ + LPStorage( + name="cell", + capacity=10, + var_capacity=MockNumVar(0, 10, "storage_capacity=cell %s" % suffix), + flow_in=1, + var_flow_in=MockNumVar(0, 1, "storage_flow_in=cell %s" % suffix), + flow_out=1, + var_flow_out=MockNumVar(0, 1, "storage_flow_out=cell %s" % suffix), + cost=1, + init_capacity=2, + eff=0.9, + ) + ] + out_node_0 = LPNode( + consumptions=[], productions=[], storages=out_stor_0, links=[] + ) + + self.assertEqual( + out_node_0, mapper.get_node_var(network="default", node="a", t=0, scn=0) + ) + + suffix = "inside network=default on node=a at t=1 for scn=1" + out_stor_1 = [ + LPStorage( + name="cell", + capacity=10, + var_capacity=MockNumVar(0, 10, "storage_capacity=cell %s" % suffix), + flow_in=1, + var_flow_in=MockNumVar(0, 1, "storage_flow_in=cell %s" % suffix), + flow_out=1, + var_flow_out=MockNumVar(0, 1, "storage_flow_out=cell %s" % suffix), + cost=1, + init_capacity=2, + eff=0.9, + ) + ] + out_node_1 = LPNode( + consumptions=[], productions=[], storages=out_stor_1, links=[] + ) + + self.assertEqual( + out_node_1, mapper.get_node_var(network="default", node="a", t=1, scn=1) + ) def test_map_links(self): # Input - study = Study(horizon=2, nb_scn=2) \ - .network()\ - .node('a')\ - .node('be')\ - .link(src='a', dest='be', quantity=[[10, 3], [20, 30]], cost=[[.01, .3], [.02, .03]])\ + study = ( + Study(horizon=2, nb_scn=2) + .network() + .node("a") + .node("be") + .link( + src="a", + dest="be", + quantity=[[10, 3], [20, 30]], + cost=[[0.01, 0.3], [0.02, 0.03]], + ) .build() + ) s = MockSolver() mapper = InputMapper(solver=s, study=study) # Expected - suffix = 'inside network=default on node=a at t=0 for scn=0' - out_link_0 = [LPLink(src='a', dest='be', cost=.01, quantity=10, variable=MockNumVar(0, 10.0, 'link=be %s' % suffix))] - out_node_0 = LPNode(consumptions=[], productions=[], storages=[], links=out_link_0) - - self.assertEqual(out_node_0, mapper.get_node_var(network='default', node='a', t=0, scn=0)) - - suffix = 'inside network=default on node=a at t=1 for scn=1' - out_link_1 = [LPLink(src='a', dest='be', cost=.03, quantity=30, variable=MockNumVar(0, 30.0, 'link=be %s' % suffix))] - out_node_1 = LPNode(consumptions=[], productions=[], storages=[],links=out_link_1) - - self.assertEqual(out_node_1, mapper.get_node_var(network='default', node='a', t=1, scn=1)) + suffix = "inside network=default on node=a at t=0 for scn=0" + out_link_0 = [ + LPLink( + src="a", + dest="be", + cost=0.01, + quantity=10, + variable=MockNumVar(0, 10.0, "link=be %s" % suffix), + ) + ] + out_node_0 = LPNode( + consumptions=[], productions=[], storages=[], links=out_link_0 + ) + + self.assertEqual( + out_node_0, mapper.get_node_var(network="default", node="a", t=0, scn=0) + ) + + suffix = "inside network=default on node=a at t=1 for scn=1" + out_link_1 = [ + LPLink( + src="a", + dest="be", + cost=0.03, + quantity=30, + variable=MockNumVar(0, 30.0, "link=be %s" % suffix), + ) + ] + out_node_1 = LPNode( + consumptions=[], productions=[], storages=[], links=out_link_1 + ) + + self.assertEqual( + out_node_1, mapper.get_node_var(network="default", node="a", t=1, scn=1) + ) def test_map_converter(self): # Mock s = MockSolver() # Input - study = Study(horizon=1)\ - .network('gas')\ - .node('a')\ - .to_converter(name='conv', ratio=.5)\ - .network()\ - .node('b')\ - .converter(name='conv', to_network='default', to_node='b', max=100)\ + study = ( + Study(horizon=1) + .network("gas") + .node("a") + .to_converter(name="conv", ratio=0.5) + .network() + .node("b") + .converter(name="conv", to_network="default", to_node="b", max=100) .build() + ) mapper = InputMapper(solver=s, study=study) # Expected - suffix = 'at t=0 for scn=0' - out_conv_0 = LPConverter(name='conv', src_ratios={('gas', 'a'): 0.5}, dest_network='default', dest_node='b', - cost=0, max=100, - var_flow_dest=MockNumVar(0, 100, 'flow_dest conv %s' % suffix), - var_flow_src={('gas', 'a'): MockNumVar(0, 200, 'flow_src conv gas:a %s' % suffix)}) - - self.assertEqual(out_conv_0, mapper.get_conv_var(name='conv', t=0, scn=0)) + suffix = "at t=0 for scn=0" + out_conv_0 = LPConverter( + name="conv", + src_ratios={("gas", "a"): 0.5}, + dest_network="default", + dest_node="b", + cost=0, + max=100, + var_flow_dest=MockNumVar(0, 100, "flow_dest conv %s" % suffix), + var_flow_src={ + ("gas", "a"): MockNumVar(0, 200, "flow_src conv gas:a %s" % suffix) + }, + ) + + self.assertEqual(out_conv_0, mapper.get_conv_var(name="conv", t=0, scn=0)) class TestOutputMapper(unittest.TestCase): def test_map_consumption(self): # Input - study = Study(horizon=2, nb_scn=2) \ - .network()\ - .node('a')\ - .consumption(name='load', quantity=[[10, 1], [20, 2]], cost=[[.01, .1], [.02, .2]])\ + study = ( + Study(horizon=2, nb_scn=2) + .network() + .node("a") + .consumption( + name="load", + quantity=[[10, 1], [20, 2]], + cost=[[0.01, 0.1], [0.02, 0.2]], + ) .build() + ) mapper = OutputMapper(study=study) - out_cons_0 = [LPConsumption(name='load', cost=.01, quantity=10, variable=5)] - mapper.set_node_var(network='default', node='a', t=0, scn=0, - vars=LPNode(consumptions=out_cons_0, productions=[], storages=[], links=[])) - - out_cons_1 = [LPConsumption(name='load', cost=.2, quantity=20, variable=5)] - mapper.set_node_var(network='default', node='a', t=1, scn=1, - vars=LPNode(consumptions=out_cons_1, productions=[], storages=[], links=[])) + out_cons_0 = [LPConsumption(name="load", cost=0.01, quantity=10, variable=5)] + mapper.set_node_var( + network="default", + node="a", + t=0, + scn=0, + vars=LPNode(consumptions=out_cons_0, productions=[], storages=[], links=[]), + ) + + out_cons_1 = [LPConsumption(name="load", cost=0.2, quantity=20, variable=5)] + mapper.set_node_var( + network="default", + node="a", + t=1, + scn=1, + vars=LPNode(consumptions=out_cons_1, productions=[], storages=[], links=[]), + ) # Expected - cons = OutputConsumption(name='load', quantity=[[5, 0], [0, 15]]) - nodes = {'a': OutputNode(consumptions=[cons], productions=[], storages=[], links=[])} - expected = Result(networks={'default': OutputNetwork(nodes=nodes)}, converters={}) + cons = OutputConsumption(name="load", quantity=[[5, 0], [0, 15]]) + nodes = { + "a": OutputNode(consumptions=[cons], productions=[], storages=[], links=[]) + } + expected = Result( + networks={"default": OutputNetwork(nodes=nodes)}, converters={} + ) assert_result(self, expected=expected, result=mapper.get_result()) def test_map_production(self): # Input - study = Study(horizon=2, nb_scn=2) \ - .network()\ - .node('a')\ - .production(name='nuclear', quantity=[[12, 2], [21, 20]], cost=[[0.12, 0.2], [0.21, 0.02]]) \ + study = ( + Study(horizon=2, nb_scn=2) + .network() + .node("a") + .production( + name="nuclear", + quantity=[[12, 2], [21, 20]], + cost=[[0.12, 0.2], [0.21, 0.02]], + ) .build() + ) mapper = OutputMapper(study=study) - out_prod_0 = [LPProduction(name='nuclear', cost=.12, quantity=12, variable=12)] - mapper.set_node_var(network='default', node='a', t=0, scn=0, - vars=LPNode(consumptions=[], productions=out_prod_0, storages=[], links=[])) - - out_prod_1 = [LPProduction(name='nuclear', cost=.21, quantity=2, variable=112)] - mapper.set_node_var(network='default', node='a', t=1, scn=1, - vars=LPNode(consumptions=[], productions=out_prod_1, storages=[], links=[])) + out_prod_0 = [LPProduction(name="nuclear", cost=0.12, quantity=12, variable=12)] + mapper.set_node_var( + network="default", + node="a", + t=0, + scn=0, + vars=LPNode(consumptions=[], productions=out_prod_0, storages=[], links=[]), + ) + + out_prod_1 = [LPProduction(name="nuclear", cost=0.21, quantity=2, variable=112)] + mapper.set_node_var( + network="default", + node="a", + t=1, + scn=1, + vars=LPNode(consumptions=[], productions=out_prod_1, storages=[], links=[]), + ) # Expected - prod = OutputProduction(name='nuclear', quantity=[[12, 0], [0, 112]]) - nodes = {'a': OutputNode(consumptions=[], productions=[prod], storages=[], links=[])} - expected = Result(networks={'default': OutputNetwork(nodes=nodes)}, converters={}) + prod = OutputProduction(name="nuclear", quantity=[[12, 0], [0, 112]]) + nodes = { + "a": OutputNode(consumptions=[], productions=[prod], storages=[], links=[]) + } + expected = Result( + networks={"default": OutputNetwork(nodes=nodes)}, converters={} + ) assert_result(self, expected=expected, result=mapper.get_result()) def test_map_storage(self): # Input - study = Study(horizon=2, nb_scn=2) \ - .network()\ - .node('a')\ - .storage(name='cell', capacity=10, flow_in=1, flow_out=1, cost=1, init_capacity=2, eff=.9) \ + study = ( + Study(horizon=2, nb_scn=2) + .network() + .node("a") + .storage( + name="cell", + capacity=10, + flow_in=1, + flow_out=1, + cost=1, + init_capacity=2, + eff=0.9, + ) .build() + ) mapper = OutputMapper(study=study) - out_stor_0 = [LPStorage(name='cell', capacity=10, flow_in=1, flow_out=1, init_capacity=2, eff=.9, cost=1, - var_capacity=5, var_flow_in=2, var_flow_out=4)] - mapper.set_node_var(network='default', node='a', t=0, scn=0, - vars=LPNode(consumptions=[], productions=[], storages=out_stor_0, links=[])) - - out_stor_1 = [LPStorage(name='cell', capacity=10, flow_in=1, flow_out=1, init_capacity=2, eff=.9, cost=1, - var_capacity=55, var_flow_in=22, var_flow_out=44)] - mapper.set_node_var(network='default', node='a', t=1, scn=1, - vars=LPNode(consumptions=[], productions=[], storages=out_stor_1, links=[])) + out_stor_0 = [ + LPStorage( + name="cell", + capacity=10, + flow_in=1, + flow_out=1, + init_capacity=2, + eff=0.9, + cost=1, + var_capacity=5, + var_flow_in=2, + var_flow_out=4, + ) + ] + mapper.set_node_var( + network="default", + node="a", + t=0, + scn=0, + vars=LPNode(consumptions=[], productions=[], storages=out_stor_0, links=[]), + ) + + out_stor_1 = [ + LPStorage( + name="cell", + capacity=10, + flow_in=1, + flow_out=1, + init_capacity=2, + eff=0.9, + cost=1, + var_capacity=55, + var_flow_in=22, + var_flow_out=44, + ) + ] + mapper.set_node_var( + network="default", + node="a", + t=1, + scn=1, + vars=LPNode(consumptions=[], productions=[], storages=out_stor_1, links=[]), + ) # Expected - stor = OutputStorage(name='cell', capacity=[[5, 0], [0, 55]], flow_in=[[2, 0], [0, 22]], flow_out=[[4, 0], [0, 44]]) - nodes = {'a': OutputNode(consumptions=[], productions=[], storages=[stor], links=[])} - expected = Result(networks={'default': OutputNetwork(nodes=nodes)}, converters={}) + stor = OutputStorage( + name="cell", + capacity=[[5, 0], [0, 55]], + flow_in=[[2, 0], [0, 22]], + flow_out=[[4, 0], [0, 44]], + ) + nodes = { + "a": OutputNode(consumptions=[], productions=[], storages=[stor], links=[]) + } + expected = Result( + networks={"default": OutputNetwork(nodes=nodes)}, converters={} + ) assert_result(self, expected=expected, result=mapper.get_result()) def test_map_link(self): # Input - study = Study(horizon=2, nb_scn=2) \ - .network()\ - .node('a')\ - .node('be')\ - .link(src='a', dest='be', quantity=[[10, 3], [20, 30]], cost=[[.01, .3], [.02, .03]])\ + study = ( + Study(horizon=2, nb_scn=2) + .network() + .node("a") + .node("be") + .link( + src="a", + dest="be", + quantity=[[10, 3], [20, 30]], + cost=[[0.01, 0.3], [0.02, 0.03]], + ) .build() + ) mapper = OutputMapper(study=study) - out_link_0 = [LPLink(src='a', dest='be', cost=.01, quantity=10, variable=8)] - mapper.set_node_var(network='default', node='a', t=0, scn=0, - vars=LPNode(consumptions=[], productions=[], storages=[], links=out_link_0)) - - out_link_1 = [LPLink(src='a', dest='be', cost=.02, quantity=10, variable=18)] - mapper.set_node_var(network='default', node='a', t=1, scn=1, - vars=LPNode(consumptions=[], productions=[], storages=[], links=out_link_1)) + out_link_0 = [LPLink(src="a", dest="be", cost=0.01, quantity=10, variable=8)] + mapper.set_node_var( + network="default", + node="a", + t=0, + scn=0, + vars=LPNode(consumptions=[], productions=[], storages=[], links=out_link_0), + ) + + out_link_1 = [LPLink(src="a", dest="be", cost=0.02, quantity=10, variable=18)] + mapper.set_node_var( + network="default", + node="a", + t=1, + scn=1, + vars=LPNode(consumptions=[], productions=[], storages=[], links=out_link_1), + ) # Expected - link = OutputLink(dest='be', quantity=[[8, 0], [0, 18]]) - nodes = {'a': OutputNode(consumptions=[], productions=[], storages=[], links=[link]), - 'be': OutputNode(consumptions=[], productions=[], storages=[], links=[])} - expected = Result(networks={'default': OutputNetwork(nodes=nodes)}, converters={}) + link = OutputLink(dest="be", quantity=[[8, 0], [0, 18]]) + nodes = { + "a": OutputNode(consumptions=[], productions=[], storages=[], links=[link]), + "be": OutputNode(consumptions=[], productions=[], storages=[], links=[]), + } + expected = Result( + networks={"default": OutputNetwork(nodes=nodes)}, converters={} + ) assert_result(self, expected=expected, result=mapper.get_result()) def test_map_converter(self): # Input - study = Study(horizon=1)\ - .network('gas')\ - .node('a')\ - .to_converter(name='conv', ratio=.5)\ - .network()\ - .node('b')\ - .converter(name='conv', to_network='default', to_node='b', max=100)\ + study = ( + Study(horizon=1) + .network("gas") + .node("a") + .to_converter(name="conv", ratio=0.5) + .network() + .node("b") + .converter(name="conv", to_network="default", to_node="b", max=100) .build() + ) # Expected - exp = OutputConverter(name='conv', flow_src={('gas', 'a'): [[200]]}, flow_dest=[[100]]) + exp = OutputConverter( + name="conv", flow_src={("gas", "a"): [[200]]}, flow_dest=[[100]] + ) blank_node = OutputNode(consumptions=[], productions=[], storages=[], links=[]) mapper = OutputMapper(study=study) - vars = LPConverter(name='conv', src_ratios={('gas', 'a'): 0.5}, dest_network='default', dest_node='b', - cost=0, max=100, var_flow_dest=100, var_flow_src={('gas', 'a'): 200}) - mapper.set_converter_var(name='conv', t=0, scn=0, vars=vars) + vars = LPConverter( + name="conv", + src_ratios={("gas", "a"): 0.5}, + dest_network="default", + dest_node="b", + cost=0, + max=100, + var_flow_dest=100, + var_flow_src={("gas", "a"): 200}, + ) + mapper.set_converter_var(name="conv", t=0, scn=0, vars=vars) res = mapper.get_result() - self.assertEqual(Result(networks={'gas': OutputNetwork(nodes={'a': blank_node}), - 'default': OutputNetwork(nodes={'b': blank_node})}, - converters={'conv': exp}), res) - + self.assertEqual( + Result( + networks={ + "gas": OutputNetwork(nodes={"a": blank_node}), + "default": OutputNetwork(nodes={"b": blank_node}), + }, + converters={"conv": exp}, + ), + res, + ) diff --git a/tests/optimizer/lp/test_optimizer.py b/tests/optimizer/lp/test_optimizer.py index e39d3ae..3037693 100644 --- a/tests/optimizer/lp/test_optimizer.py +++ b/tests/optimizer/lp/test_optimizer.py @@ -10,37 +10,94 @@ import msgpack from hadar.optimizer.domain.input import Study -from hadar.optimizer.lp.domain import LPConsumption, LPProduction, LPLink, LPNode, LPStorage, \ - LPConverter, LPTimeStep, LPNetwork +from hadar.optimizer.lp.domain import ( + LPConsumption, + LPProduction, + LPLink, + LPNode, + LPStorage, + LPConverter, + LPTimeStep, + LPNetwork, +) from hadar.optimizer.lp.mapper import InputMapper, OutputMapper -from hadar.optimizer.lp.optimizer import ObjectiveBuilder, AdequacyBuilder, _solve_batch, StorageBuilder, \ - ConverterMixBuilder +from hadar.optimizer.lp.optimizer import ( + ObjectiveBuilder, + AdequacyBuilder, + _solve_batch, + StorageBuilder, + ConverterMixBuilder, +) from hadar.optimizer.lp.optimizer import solve_lp -from hadar.optimizer.domain.output import OutputConsumption, OutputNode, Result, OutputNetwork, OutputConverter -from tests.optimizer.lp.ortools_mock import MockConstraint, MockNumVar, MockObjective, MockSolver +from hadar.optimizer.domain.output import ( + OutputConsumption, + OutputNode, + Result, + OutputNetwork, + OutputConverter, +) +from tests.optimizer.lp.ortools_mock import ( + MockConstraint, + MockNumVar, + MockObjective, + MockSolver, +) class TestObjectiveBuilder(unittest.TestCase): - def test_add_node(self): # Mock objective = MockObjective() solver = MockSolver() # Input - consumptions = [LPConsumption(name='load', quantity=10, cost=10, variable=MockNumVar(0, 10, 'load'))] - productions = [LPProduction(name='solar', quantity=10, cost=20, variable=MockNumVar(0, 20, 'solar'))] - storages = [LPStorage(name='cell', capacity=10, var_capacity=MockNumVar(0, 10, 'cell_capacity'), cost=1, - flow_in=1, var_flow_in=MockNumVar(0, 1, 'cell_flow_in'), - flow_out=10, var_flow_out=MockNumVar(0, 10, 'cell_flow_out'), - init_capacity=2, eff=1.2 - )] - links = [LPLink(src='fr', dest='be', quantity=10, cost=30, variable=MockNumVar(0, 30, 'be'))] - node = LPNode(consumptions=consumptions, productions=productions, storages=storages, links=links) + consumptions = [ + LPConsumption( + name="load", quantity=10, cost=10, variable=MockNumVar(0, 10, "load") + ) + ] + productions = [ + LPProduction( + name="solar", quantity=10, cost=20, variable=MockNumVar(0, 20, "solar") + ) + ] + storages = [ + LPStorage( + name="cell", + capacity=10, + var_capacity=MockNumVar(0, 10, "cell_capacity"), + cost=1, + flow_in=1, + var_flow_in=MockNumVar(0, 1, "cell_flow_in"), + flow_out=10, + var_flow_out=MockNumVar(0, 10, "cell_flow_out"), + init_capacity=2, + eff=1.2, + ) + ] + links = [ + LPLink( + src="fr", + dest="be", + quantity=10, + cost=30, + variable=MockNumVar(0, 30, "be"), + ) + ] + node = LPNode( + consumptions=consumptions, + productions=productions, + storages=storages, + links=links, + ) # Expected - coeffs = {MockNumVar(0, 10, 'load'): 10, MockNumVar(0, 20, 'solar'): 20, MockNumVar(0, 30, 'be'): 30, - MockNumVar(0, 10, 'cell_capacity'): 1} + coeffs = { + MockNumVar(0, 10, "load"): 10, + MockNumVar(0, 20, "solar"): 20, + MockNumVar(0, 30, "be"): 30, + MockNumVar(0, 10, "cell_capacity"): 1, + } expected = MockObjective(min=True, coeffs=coeffs) # Test @@ -55,12 +112,21 @@ def test_add_converter(self): solver = MockSolver() # Input - conv = LPConverter(name='conv', src_ratios={('gas', 'a'): 0.5}, dest_network='default', dest_node='b', - cost=10, max=100, var_flow_dest=MockNumVar(0, 100, 'flow_dest conv %s'), - var_flow_src={('gas', 'a'): MockNumVar(0, 200, 'flow_src conv gas:a %s')}) + conv = LPConverter( + name="conv", + src_ratios={("gas", "a"): 0.5}, + dest_network="default", + dest_node="b", + cost=10, + max=100, + var_flow_dest=MockNumVar(0, 100, "flow_dest conv %s"), + var_flow_src={("gas", "a"): MockNumVar(0, 200, "flow_src conv gas:a %s")}, + ) # Expected - expected = MockObjective(min=True, coeffs={MockNumVar(0, 100, 'flow_dest conv %s'): 10}) + expected = MockObjective( + min=True, coeffs={MockNumVar(0, 100, "flow_dest conv %s"): 10} + ) # Test builder = ObjectiveBuilder(solver=solver) @@ -71,59 +137,106 @@ def test_add_converter(self): class TestAdequacyBuilder(unittest.TestCase): - def test_add_node(self): # Mock solver = MockSolver() # Input - fr_consumptions = [LPConsumption(name='load', quantity=10, cost=10, variable=MockNumVar(0, 10, 'load'))] - fr_productions = [LPProduction(name='solar', quantity=10, cost=20, variable=MockNumVar(0, 20, 'solar'))] - fr_storages = [LPStorage(name='cell', capacity=10, var_capacity=MockNumVar(0, 10, 'cell_capacity'), cost=1, - flow_in=1, var_flow_in=MockNumVar(0, 1, 'cell_flow_in'), - flow_out=10, var_flow_out=MockNumVar(0, 10, 'cell_flow_out'), - init_capacity=2, eff=1.2)] - fr_links = [LPLink(src='fr', dest='be', quantity=10, cost=30, variable=MockNumVar(0, 30, 'be'))] - fr_node = LPNode(consumptions=fr_consumptions, productions=fr_productions, storages=fr_storages, links=fr_links) + fr_consumptions = [ + LPConsumption( + name="load", quantity=10, cost=10, variable=MockNumVar(0, 10, "load") + ) + ] + fr_productions = [ + LPProduction( + name="solar", quantity=10, cost=20, variable=MockNumVar(0, 20, "solar") + ) + ] + fr_storages = [ + LPStorage( + name="cell", + capacity=10, + var_capacity=MockNumVar(0, 10, "cell_capacity"), + cost=1, + flow_in=1, + var_flow_in=MockNumVar(0, 1, "cell_flow_in"), + flow_out=10, + var_flow_out=MockNumVar(0, 10, "cell_flow_out"), + init_capacity=2, + eff=1.2, + ) + ] + fr_links = [ + LPLink( + src="fr", + dest="be", + quantity=10, + cost=30, + variable=MockNumVar(0, 30, "be"), + ) + ] + fr_node = LPNode( + consumptions=fr_consumptions, + productions=fr_productions, + storages=fr_storages, + links=fr_links, + ) be_node = LPNode(consumptions=[], productions=[], storages=[], links=[]) # Expected - fr_coeffs = {MockNumVar(0, 10, 'load'): 1, MockNumVar(0, 20, 'solar'): 1, - MockNumVar(0, 1, 'cell_flow_in'): -1, MockNumVar(0, 10, 'cell_flow_out'): 1, - MockNumVar(0, 30, 'be'): -1} + fr_coeffs = { + MockNumVar(0, 10, "load"): 1, + MockNumVar(0, 20, "solar"): 1, + MockNumVar(0, 1, "cell_flow_in"): -1, + MockNumVar(0, 10, "cell_flow_out"): 1, + MockNumVar(0, 30, "be"): -1, + } fr_constraint = MockConstraint(10, 10, coeffs=fr_coeffs) - be_coeffs = {MockNumVar(0, 30, 'be'): 1} + be_coeffs = {MockNumVar(0, 30, "be"): 1} be_constraint = MockConstraint(0, 0, coeffs=be_coeffs) # Test builder = AdequacyBuilder(solver=solver) - builder.add_node(name_network='default', name_node='fr', node=fr_node, t=0) - builder.add_node(name_network='default', name_node='be', node=be_node, t=0) + builder.add_node(name_network="default", name_node="fr", node=fr_node, t=0) + builder.add_node(name_network="default", name_node="be", node=be_node, t=0) builder.build() - self.assertEqual(fr_constraint, builder.constraints[(0, 'default', 'fr')]) - self.assertEqual(be_constraint, builder.constraints[(0, 'default', 'be')]) + self.assertEqual(fr_constraint, builder.constraints[(0, "default", "fr")]) + self.assertEqual(be_constraint, builder.constraints[(0, "default", "be")]) def test_add_converter(self): # Mock solver = MockSolver() # Input - conv = LPConverter(name='conv', src_ratios={('gas', 'a'): 0.5}, dest_network='default', dest_node='b', - cost=10, max=100, var_flow_dest=MockNumVar(0, 100, 'flow_dest conv %s'), - var_flow_src={('gas', 'a'): MockNumVar(0, 200, 'flow_src conv gas:a %s')}) + conv = LPConverter( + name="conv", + src_ratios={("gas", "a"): 0.5}, + dest_network="default", + dest_node="b", + cost=10, + max=100, + var_flow_dest=MockNumVar(0, 100, "flow_dest conv %s"), + var_flow_src={("gas", "a"): MockNumVar(0, 200, "flow_src conv gas:a %s")}, + ) adequacy = AdequacyBuilder(solver=solver) - adequacy.constraints[(0, 'gas', 'a')] = MockConstraint(10, 10, coeffs={}) - adequacy.constraints[(0, 'default', 'b')] = MockConstraint(10, 10, coeffs={}) + adequacy.constraints[(0, "gas", "a")] = MockConstraint(10, 10, coeffs={}) + adequacy.constraints[(0, "default", "b")] = MockConstraint(10, 10, coeffs={}) # Test adequacy.add_converter(conv=conv, t=0) - self.assertEqual({MockNumVar(0, 100, 'flow_dest conv %s'): 1}, adequacy.constraints[(0, 'default', 'b')].coeffs) - self.assertEqual({MockNumVar(0, 200, 'flow_src conv gas:a %s'): -1}, adequacy.constraints[(0, 'gas', 'a')].coeffs) + self.assertEqual( + {MockNumVar(0, 100, "flow_dest conv %s"): 1}, + adequacy.constraints[(0, "default", "b")].coeffs, + ) + self.assertEqual( + {MockNumVar(0, 200, "flow_src conv gas:a %s"): -1}, + adequacy.constraints[(0, "gas", "a")].coeffs, + ) class TestStorageBuilder(unittest.TestCase): @@ -132,51 +245,78 @@ def test_t0(self): solver = MockSolver() # Input - c0 = MockNumVar(0, 10, 'cell_capacity') - storages = [LPStorage(name='cell', capacity=10, var_capacity=c0, cost=1, - flow_in=1, var_flow_in=MockNumVar(0, 1, 'cell_flow_in'), - flow_out=10, var_flow_out=MockNumVar(0, 10, 'cell_flow_out'), - init_capacity=2, eff=1.2)] + c0 = MockNumVar(0, 10, "cell_capacity") + storages = [ + LPStorage( + name="cell", + capacity=10, + var_capacity=c0, + cost=1, + flow_in=1, + var_flow_in=MockNumVar(0, 1, "cell_flow_in"), + flow_out=10, + var_flow_out=MockNumVar(0, 10, "cell_flow_out"), + init_capacity=2, + eff=1.2, + ) + ] node = LPNode(consumptions=[], productions=[], storages=storages, links=[]) # Expected - coeffs = {MockNumVar(0, 1, 'cell_flow_in'): -1.2, MockNumVar(0, 10, 'cell_flow_out'): 1, - c0: 1} + coeffs = { + MockNumVar(0, 1, "cell_flow_in"): -1.2, + MockNumVar(0, 10, "cell_flow_out"): 1, + c0: 1, + } constraint = MockConstraint(2, 2, coeffs=coeffs) # Test builder = StorageBuilder(solver=solver) - res = builder.add_node(name_network='default', name_node='fr', node=node, t=0) + res = builder.add_node(name_network="default", name_node="fr", node=node, t=0) self.assertEqual(constraint, res) - self.assertEqual(builder.capacities[(0, 'default', 'fr', 'cell')], c0) + self.assertEqual(builder.capacities[(0, "default", "fr", "cell")], c0) def test(self): # Mock solver = MockSolver() # Input - storages = [LPStorage(name='cell', capacity=10, var_capacity=MockNumVar(0, 10, 'cell_capacity at 1'), cost=1, - flow_in=1, var_flow_in=MockNumVar(0, 1, 'cell_flow_in'), - flow_out=10, var_flow_out=MockNumVar(0, 10, 'cell_flow_out'), - init_capacity=2, eff=1.2)] + storages = [ + LPStorage( + name="cell", + capacity=10, + var_capacity=MockNumVar(0, 10, "cell_capacity at 1"), + cost=1, + flow_in=1, + var_flow_in=MockNumVar(0, 1, "cell_flow_in"), + flow_out=10, + var_flow_out=MockNumVar(0, 10, "cell_flow_out"), + init_capacity=2, + eff=1.2, + ) + ] node = LPNode(consumptions=[], productions=[], storages=storages, links=[]) - c0 = MockNumVar(0, 11, 'cell_capacity at 0') - c1 = MockNumVar(0, 10, 'cell_capacity at 1') + c0 = MockNumVar(0, 11, "cell_capacity at 0") + c1 = MockNumVar(0, 10, "cell_capacity at 1") # Expected - coeffs = {MockNumVar(0, 1, 'cell_flow_in'): -1.2, MockNumVar(0, 10, 'cell_flow_out'): 1, - c0: -1, c1: 1} + coeffs = { + MockNumVar(0, 1, "cell_flow_in"): -1.2, + MockNumVar(0, 10, "cell_flow_out"): 1, + c0: -1, + c1: 1, + } constraint = MockConstraint(0, 0, coeffs=coeffs) # Test builder = StorageBuilder(solver=solver) - builder.capacities[(0, 'default', 'fr', 'cell')] = c0 - res = builder.add_node(name_network='default', name_node='fr', node=node, t=1) + builder.capacities[(0, "default", "fr", "cell")] = c0 + res = builder.add_node(name_network="default", name_node="fr", node=node, t=1) self.assertEqual(constraint, res) - self.assertEqual(c1, builder.capacities[(1, 'default', 'fr', 'cell')]) + self.assertEqual(c1, builder.capacities[(1, "default", "fr", "cell")]) class TestConverterMixBuilder(unittest.TestCase): @@ -185,13 +325,26 @@ def test(self): solver = MockSolver() # Input - conv = LPConverter(name='conv', src_ratios={('gas', 'a'): 0.5}, dest_network='default', dest_node='b', - cost=10, max=100, var_flow_dest=MockNumVar(0, 100, 'flow_dest conv %s'), - var_flow_src={('gas', 'a'): MockNumVar(0, 200, 'flow_src conv gas:a %s')}) + conv = LPConverter( + name="conv", + src_ratios={("gas", "a"): 0.5}, + dest_network="default", + dest_node="b", + cost=10, + max=100, + var_flow_dest=MockNumVar(0, 100, "flow_dest conv %s"), + var_flow_src={("gas", "a"): MockNumVar(0, 200, "flow_src conv gas:a %s")}, + ) # Expected - expected = MockConstraint(0, 0, coeffs={MockNumVar(0, 100, 'flow_dest conv %s'): -1, - MockNumVar(0, 200, 'flow_src conv gas:a %s'): 0.5}) + expected = MockConstraint( + 0, + 0, + coeffs={ + MockNumVar(0, 100, "flow_dest conv %s"): -1, + MockNumVar(0, 200, "flow_src conv gas:a %s"): 0.5, + }, + ) # Test builder = ConverterMixBuilder(solver=solver) @@ -202,14 +355,17 @@ def test(self): class TestSolve(unittest.TestCase): def test_solve_batch(self): # Input - study = Study(horizon=1, nb_scn=1) \ - .network()\ - .node('a')\ - .consumption(name='load', cost=10, quantity=10)\ - .to_converter(name='conv')\ - .network('gas').node('b')\ - .converter(name='conv', to_network='gas', to_node='b', max=10, cost=1)\ + study = ( + Study(horizon=1, nb_scn=1) + .network() + .node("a") + .consumption(name="load", cost=10, quantity=10) + .to_converter(name="conv") + .network("gas") + .node("b") + .converter(name="conv", to_network="gas", to_node="b", max=10, cost=1) .build() + ) # Mock solver = MockSolver() @@ -233,52 +389,96 @@ def test_solve_batch(self): mix.add_converter = MagicMock() mix.build = MagicMock() - in_cons = LPConsumption(name='load', quantity=10, cost=10, variable=MockNumVar(0, 10, 'load')) + in_cons = LPConsumption( + name="load", quantity=10, cost=10, variable=MockNumVar(0, 10, "load") + ) var_node = LPNode(consumptions=[in_cons], productions=[], storages=[], links=[]) empty_node = LPNode(consumptions=[], productions=[], storages=[], links=[]) - var_conv = LPConverter(name='conv', src_ratios={('default', 'a'): .5}, - var_flow_src={('default', 'a'): MockNumVar(0, 10, 'conv src')}, - dest_network='gas', dest_node='b', max=10, cost=1, - var_flow_dest=MockNumVar(0, 10, 'conv dest')) + var_conv = LPConverter( + name="conv", + src_ratios={("default", "a"): 0.5}, + var_flow_src={("default", "a"): MockNumVar(0, 10, "conv src")}, + dest_network="gas", + dest_node="b", + max=10, + cost=1, + var_flow_dest=MockNumVar(0, 10, "conv dest"), + ) def side_effect(network, node, t, scn): - return var_node if network == 'default' and node == 'a' else empty_node + return var_node if network == "default" and node == "a" else empty_node + in_mapper = InputMapper(solver=solver, study=study) in_mapper.get_node_var = Mock(side_effect=side_effect) - exp_var_conv = LPConverter(name='conv', src_ratios={('default', 'a'): .5}, max=10, cost=1, - var_flow_src={('default', 'a'): MockNumVar(0, 10, 'conv src')}, - dest_network='gas', dest_node='b', var_flow_dest=MockNumVar(0, 10, 'conv dest')) + exp_var_conv = LPConverter( + name="conv", + src_ratios={("default", "a"): 0.5}, + max=10, + cost=1, + var_flow_src={("default", "a"): MockNumVar(0, 10, "conv src")}, + dest_network="gas", + dest_node="b", + var_flow_dest=MockNumVar(0, 10, "conv dest"), + ) in_mapper.get_conv_var = Mock(return_value=exp_var_conv) # Expected - in_cons = LPConsumption(name='load', quantity=10, cost=10, variable=10) - exp_var_node = LPNode(consumptions=[in_cons], productions=[], storages=[], links=[]) - exp_var_conv = LPConverter(name='conv', src_ratios={('default', 'a'): .5}, - var_flow_src={('default', 'a'): 10}, - dest_network='gas', dest_node='b', max=10, cost=1, - var_flow_dest=10) - - expected = LPTimeStep(networks={'default': LPNetwork(nodes={'a': exp_var_node}), - 'gas': LPNetwork(nodes={'b': empty_node})}, - converters={'conv': exp_var_conv}) + in_cons = LPConsumption(name="load", quantity=10, cost=10, variable=10) + exp_var_node = LPNode( + consumptions=[in_cons], productions=[], storages=[], links=[] + ) + exp_var_conv = LPConverter( + name="conv", + src_ratios={("default", "a"): 0.5}, + var_flow_src={("default", "a"): 10}, + dest_network="gas", + dest_node="b", + max=10, + cost=1, + var_flow_dest=10, + ) + + expected = LPTimeStep( + networks={ + "default": LPNetwork(nodes={"a": exp_var_node}), + "gas": LPNetwork(nodes={"b": empty_node}), + }, + converters={"conv": exp_var_conv}, + ) # Test - res = _solve_batch((study, 0, solver, objective, adequacy, storage, mix, in_mapper)) - res, t_mod, t_sol = msgpack.unpackb(res, use_list=False, raw=False) + res = _solve_batch( + (study, 0, solver, objective, adequacy, storage, mix, in_mapper) + ) + res, t_mod, t_sol = msgpack.unpackb(res, use_list=False, raw=False) self.assertEqual([expected], [LPTimeStep.from_json(r) for r in res]) self.assertTrue(t_mod > 0) self.assertTrue(t_sol > 0) - in_mapper.get_node_var.assert_has_calls([call(network='default', node='a', t=0, scn=0), - call(network='gas', node='b', t=0, scn=0)]) - adequacy.add_node.assert_has_calls([call(name_network='default', name_node='a', t=0, node=var_node), - call(name_network='gas', name_node='b', t=0, node=empty_node)]) - storage.add_node.assert_has_calls([call(name_network='default', name_node='a', t=0, node=var_node), - call(name_network='gas', name_node='b', t=0, node=empty_node)]) + in_mapper.get_node_var.assert_has_calls( + [ + call(network="default", node="a", t=0, scn=0), + call(network="gas", node="b", t=0, scn=0), + ] + ) + adequacy.add_node.assert_has_calls( + [ + call(name_network="default", name_node="a", t=0, node=var_node), + call(name_network="gas", name_node="b", t=0, node=empty_node), + ] + ) + storage.add_node.assert_has_calls( + [ + call(name_network="default", name_node="a", t=0, node=var_node), + call(name_network="gas", name_node="b", t=0, node=empty_node), + ] + ) mix.add_converter.assert_called_with(conv=var_conv) - objective.add_node.assert_has_calls([call(node=var_node), call(node=empty_node)]) + objective.add_node.assert_has_calls( + [call(node=var_node), call(node=empty_node)] + ) objective.add_converter(conv=var_conv) objective.build.assert_called_with() @@ -290,24 +490,35 @@ def side_effect(network, node, t, scn): def test_solve(self): # Input - study = Study(horizon=1, nb_scn=1) \ - .network('gas').node('a')\ - .consumption(name='load', cost=10, quantity=10)\ - .to_converter(name='conv', ratio=0.5)\ - .network().node('b')\ - .converter(name='conv', to_network='default', to_node='b', max=10, cost=1)\ + study = ( + Study(horizon=1, nb_scn=1) + .network("gas") + .node("a") + .consumption(name="load", cost=10, quantity=10) + .to_converter(name="conv", ratio=0.5) + .network() + .node("b") + .converter(name="conv", to_network="default", to_node="b", max=10, cost=1) .build() + ) # Expected - out_node = OutputNode(consumptions=[OutputConsumption(name='load', quantity=[0])], - productions=[], storages=[], links=[]) - out_conv = OutputConverter(name='conv', flow_src={('gas', 'a'): [0]}, flow_dest=[0]) - exp_result = Result(networks={'gas': OutputNetwork(nodes={'a': out_node})}, - converters={'conv': out_conv}) + out_node = OutputNode( + consumptions=[OutputConsumption(name="load", quantity=[0])], + productions=[], + storages=[], + links=[], + ) + out_conv = OutputConverter( + name="conv", flow_src={("gas", "a"): [0]}, flow_dest=[0] + ) + exp_result = Result( + networks={"gas": OutputNetwork(nodes={"a": out_node})}, + converters={"conv": out_conv}, + ) # Mock - out_mapper = OutputMapper(study=study) out_mapper.set_node_var = MagicMock() out_mapper.set_converter_var = MagicMock() @@ -317,7 +528,12 @@ def test_solve(self): res = solve_lp(study, out_mapper) self.assertEqual(exp_result, res) - out_mapper.set_node_var.assert_has_calls([call(network='gas', node='a', t=0, scn=0, vars=ANY), - call(network='default', node='b', t=0, scn=0, vars=ANY)]) - out_mapper.set_converter_var.assert_called_with(name='conv', t=0, scn=0, vars=ANY) - + out_mapper.set_node_var.assert_has_calls( + [ + call(network="gas", node="a", t=0, scn=0, vars=ANY), + call(network="default", node="b", t=0, scn=0, vars=ANY), + ] + ) + out_mapper.set_converter_var.assert_called_with( + name="conv", t=0, scn=0, vars=ANY + ) diff --git a/tests/optimizer/remote/__init__.py b/tests/optimizer/remote/__init__.py index 84711aa..f76a769 100644 --- a/tests/optimizer/remote/__init__.py +++ b/tests/optimizer/remote/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/tests/optimizer/remote/test_optimizer.py b/tests/optimizer/remote/test_optimizer.py index 3394081..9809f80 100644 --- a/tests/optimizer/remote/test_optimizer.py +++ b/tests/optimizer/remote/test_optimizer.py @@ -11,34 +11,47 @@ from hadar import RemoteOptimizer from hadar.optimizer.domain.input import Study -from hadar.optimizer.domain.output import Result, OutputConsumption, OutputNode, OutputNetwork +from hadar.optimizer.domain.output import ( + Result, + OutputConsumption, + OutputNode, + OutputNetwork, +) from hadar.optimizer.remote.optimizer import check_code class MockSchedulerServer(BaseHTTPRequestHandler): def do_POST(self): - assert self.path == '/api/v1/study?token=' + assert self.path == "/api/v1/study?token=" - content_length = int(self.headers['Content-Length']) + content_length = int(self.headers["Content-Length"]) data = json.loads(self.rfile.read(content_length).decode()) assert isinstance(Study.from_json(data), Study) self.send_response(200) - body = json.dumps({'job': 123, 'status': 'QUEUED', 'progress': 1}).encode() - self.send_header('Content-Length', str(len(body))) + body = json.dumps({"job": 123, "status": "QUEUED", "progress": 1}).encode() + self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def do_GET(self): - assert '/api/v1/result/123?token=' == self.path - - nodes = {'a': OutputNode(consumptions=[OutputConsumption(quantity=[0], name='load')], - productions=[], storages=[], links=[])} - res = Result(networks={'default': OutputNetwork(nodes=nodes)}, converters={}) + assert "/api/v1/result/123?token=" == self.path + + nodes = { + "a": OutputNode( + consumptions=[OutputConsumption(quantity=[0], name="load")], + productions=[], + storages=[], + links=[], + ) + } + res = Result(networks={"default": OutputNetwork(nodes=nodes)}, converters={}) self.send_response(200) - body = json.dumps({'job': 123, 'status': 'TERMINATED', 'result': res.to_json()}).encode() - self.send_header('Content-Length', str(len(body))) + body = json.dumps( + {"job": 123, "status": "TERMINATED", "result": res.to_json()} + ).encode() + self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) @@ -50,20 +63,33 @@ def handle_twice(handle_request): class RemoteOptimizerTest(unittest.TestCase): def setUp(self) -> None: - self.study = Study(horizon=1) \ - .network().node('a').consumption(cost=0, quantity=[0], name='load').build() - - nodes = {'a': OutputNode(consumptions=[OutputConsumption(quantity=[0], name='load')], - productions=[], storages=[], links=[])} - self.result = Result(networks={'default': OutputNetwork(nodes=nodes)}, converters={}) + self.study = ( + Study(horizon=1) + .network() + .node("a") + .consumption(cost=0, quantity=[0], name="load") + .build() + ) + + nodes = { + "a": OutputNode( + consumptions=[OutputConsumption(quantity=[0], name="load")], + productions=[], + storages=[], + links=[], + ) + } + self.result = Result( + networks={"default": OutputNetwork(nodes=nodes)}, converters={} + ) def test_job_terminated(self): # Start server - httpd = HTTPServer(('localhost', 6984), MockSchedulerServer) + httpd = HTTPServer(("localhost", 6984), MockSchedulerServer) server = threading.Thread(None, handle_twice, None, (httpd.handle_request,)) server.start() - optim = RemoteOptimizer(url='http://localhost:6984') + optim = RemoteOptimizer(url="http://localhost:6984") res = optim.solve(self.study) self.assertEqual(self.result, res) diff --git a/tests/utils.py b/tests/utils.py index d8fbd12..570cdc9 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -13,52 +13,108 @@ def assert_result(self, expected: Result, result: Result): for name_network, network in expected.networks.items(): if name_network not in result.networks.keys(): - self.fail('Network {} expected but not'.format(name_network)) + self.fail("Network {} expected but not".format(name_network)) for name_node, node in network.nodes.items(): if name_node not in result.networks[name_network].nodes.keys(): - self.fail('Node {} expected but not'.format(name_node)) + self.fail("Node {} expected but not".format(name_node)) res = result.networks[name_network].nodes[name_node] # Consumptions for cons_expected, cons_res in zip(node.consumptions, res.consumptions): - self.assertEqual(cons_expected.name, cons_res.name, - "Consumption for node {} has different name".format(name_node)) - np.testing.assert_array_equal(cons_expected.quantity, cons_res.quantity, - 'Consumption {} for node {} has different quantity'.format(cons_expected.name, name_node)) + self.assertEqual( + cons_expected.name, + cons_res.name, + "Consumption for node {} has different name".format(name_node), + ) + np.testing.assert_array_equal( + cons_expected.quantity, + cons_res.quantity, + "Consumption {} for node {} has different quantity".format( + cons_expected.name, name_node + ), + ) # Productions for prod_expected, prod_res in zip(node.productions, res.productions): - self.assertEqual(prod_expected.name, prod_res.name, - "Production for node {} has different name".format(name_node)) - np.testing.assert_array_equal(prod_expected.quantity, prod_res.quantity, - 'Production {} for node {} has different quantity'.format(prod_expected.name, name_node)) + self.assertEqual( + prod_expected.name, + prod_res.name, + "Production for node {} has different name".format(name_node), + ) + np.testing.assert_array_equal( + prod_expected.quantity, + prod_res.quantity, + "Production {} for node {} has different quantity".format( + prod_expected.name, name_node + ), + ) # Storage for stor_expected, stor_res in zip(node.storages, res.storages): - self.assertEqual(stor_expected.name, stor_res.name, - 'Storage for node {} has different name'.format(name_node)) - np.testing.assert_array_almost_equal(stor_expected.flow_in, stor_res.flow_in, 4, - 'Storage {} for node {} has different flow in'.format(stor_res.name, name_node)) - np.testing.assert_array_almost_equal(stor_expected.flow_out, stor_res.flow_out, 4, - 'Storage {} for node {} has different flow out'.format(stor_res.name, name_node)) - np.testing.assert_array_almost_equal(stor_expected.capacity, stor_res.capacity, 4, - 'Storage {} for node {} has different capacity'.format(stor_res.name, name_node)) + self.assertEqual( + stor_expected.name, + stor_res.name, + "Storage for node {} has different name".format(name_node), + ) + np.testing.assert_array_almost_equal( + stor_expected.flow_in, + stor_res.flow_in, + 4, + "Storage {} for node {} has different flow in".format( + stor_res.name, name_node + ), + ) + np.testing.assert_array_almost_equal( + stor_expected.flow_out, + stor_res.flow_out, + 4, + "Storage {} for node {} has different flow out".format( + stor_res.name, name_node + ), + ) + np.testing.assert_array_almost_equal( + stor_expected.capacity, + stor_res.capacity, + 4, + "Storage {} for node {} has different capacity".format( + stor_res.name, name_node + ), + ) # Links for link_expected, link_res in zip(node.links, res.links): - self.assertEqual(link_expected.dest, link_res.dest, - "Link for node {} has different name".format(name_node)) - np.testing.assert_array_equal(link_expected.quantity, link_res.quantity, - 'Link {} for node {} has different quantity'.format(link_expected.dest, name_node)) + self.assertEqual( + link_expected.dest, + link_res.dest, + "Link for node {} has different name".format(name_node), + ) + np.testing.assert_array_equal( + link_expected.quantity, + link_res.quantity, + "Link {} for node {} has different quantity".format( + link_expected.dest, name_node + ), + ) # Converter for name, exp in expected.converters.items(): - self.assertTrue(name in result.converters, 'Converter {} not in result'.format(name)) + self.assertTrue( + name in result.converters, "Converter {} not in result".format(name) + ) for src, flow in exp.flow_src.items(): - self.assertTrue(src in result.converters[name].flow_src, 'Converter {} has not src {} in result'.format(name, src)) - np.testing.assert_array_equal(flow, result.converters[name].flow_src[src], - 'converter {} as different source {}'.format(name, src)) + self.assertTrue( + src in result.converters[name].flow_src, + "Converter {} has not src {} in result".format(name, src), + ) + np.testing.assert_array_equal( + flow, + result.converters[name].flow_src[src], + "converter {} as different source {}".format(name, src), + ) - np.testing.assert_array_equal(exp.flow_dest, result.converters[name].flow_dest, - 'Converter {} has different flow dest'.format(name)) + np.testing.assert_array_equal( + exp.flow_dest, + result.converters[name].flow_dest, + "Converter {} has different flow dest".format(name), + ) diff --git a/tests/viewer/__init__.py b/tests/viewer/__init__.py index 84711aa..f76a769 100644 --- a/tests/viewer/__init__.py +++ b/tests/viewer/__init__.py @@ -4,4 +4,3 @@ # If a copy of the Apache License, version 2.0 was not distributed with this file, you can obtain one at http://www.apache.org/licenses/LICENSE-2.0. # SPDX-License-Identifier: Apache-2.0 # This file is part of hadar-simulator, a python adequacy library for everyone. - diff --git a/tests/viewer/test_html.py b/tests/viewer/test_html.py index be6637d..2abcb46 100644 --- a/tests/viewer/test_html.py +++ b/tests/viewer/test_html.py @@ -21,132 +21,159 @@ class TestHTMLPlotting(unittest.TestCase): def setUp(self) -> None: - self.study = Study(horizon=3, nb_scn=2)\ - .network()\ - .node('a')\ - .consumption(cost=10 ** 6, quantity=[[20, 10, 2], [10, 5, 3]], name='load')\ - .consumption(cost=10 ** 6, quantity=[[30, 15, 3], [15, 7, 2]], name='car')\ - .production(cost=10, quantity=[[60, 30, 5], [30, 15, 3]], name='prod')\ - .node('b')\ - .consumption(cost=10 ** 6, quantity=[[40, 20, 2], [20, 10, 1]], name='load')\ - .production(cost=20, quantity=[[10, 5, 1], [5, 3, 1]], name='prod')\ - .production(cost=30, quantity=[[20, 10, 2], [10, 5, 1]], name='nuclear')\ - .link(src='a', dest='b', quantity=[[10, 10, 10], [5, 5, 5]], cost=2)\ + self.study = ( + Study(horizon=3, nb_scn=2) + .network() + .node("a") + .consumption(cost=10 ** 6, quantity=[[20, 10, 2], [10, 5, 3]], name="load") + .consumption(cost=10 ** 6, quantity=[[30, 15, 3], [15, 7, 2]], name="car") + .production(cost=10, quantity=[[60, 30, 5], [30, 15, 3]], name="prod") + .node("b") + .consumption(cost=10 ** 6, quantity=[[40, 20, 2], [20, 10, 1]], name="load") + .production(cost=20, quantity=[[10, 5, 1], [5, 3, 1]], name="prod") + .production(cost=30, quantity=[[20, 10, 2], [10, 5, 1]], name="nuclear") + .link(src="a", dest="b", quantity=[[10, 10, 10], [5, 5, 5]], cost=2) .build() + ) optimizer = LPOptimizer() self.result = optimizer.solve(study=self.study) self.agg = ResultAnalyzer(self.study, self.result) - self.plot = HTMLPlotting(agg=self.agg, unit_symbol='MW', time_start='2020-02-01', time_end='2020-02-02', - node_coord={'a': [2.33, 48.86], 'b': [4.38, 50.83]}) + self.plot = HTMLPlotting( + agg=self.agg, + unit_symbol="MW", + time_start="2020-02-01", + time_end="2020-02-02", + node_coord={"a": [2.33, 48.86], "b": [4.38, 50.83]}, + ) self.hash = hashlib.sha3_256() def test_network(self): fig = self.plot.network().map(t=0, scn=0, zoom=1.6) # Used this line to plot map: plot(fig) - self.assert_fig_hash('49d81d1457b2ac78e1fc6ae4c1fc6215b8a0bbe4', fig) + self.assert_fig_hash("49d81d1457b2ac78e1fc6ae4c1fc6215b8a0bbe4", fig) fig = self.plot.network().rac_matrix() - self.assert_fig_hash('2b87a4e781e9eeb532f5d2b091c474bb0de625fd', fig) + self.assert_fig_hash("2b87a4e781e9eeb532f5d2b091c474bb0de625fd", fig) def test_node(self): - fig = self.plot.network().node('a').stack(scn=0) - self.assert_fig_hash('d9f9f004b98ca62be934d69d4fd0c1a302512242', fig) + fig = self.plot.network().node("a").stack(scn=0) + self.assert_fig_hash("d9f9f004b98ca62be934d69d4fd0c1a302512242", fig) def test_consumption(self): - fig = self.plot.network().node('a').consumption('load').timeline() - self.assert_fig_hash('ba776202b252c9df5c81ca869b2e2d85e56e5589', fig) + fig = self.plot.network().node("a").consumption("load").timeline() + self.assert_fig_hash("ba776202b252c9df5c81ca869b2e2d85e56e5589", fig) - fig = self.plot.network().node('a').consumption('load').monotone(scn=0) - self.assert_fig_hash('1ffa51a52b066aab8cabb817c11fd1272549eb9d', fig) + fig = self.plot.network().node("a").consumption("load").monotone(scn=0) + self.assert_fig_hash("1ffa51a52b066aab8cabb817c11fd1272549eb9d", fig) - fig = self.plot.network().node('a').consumption('load').gaussian(scn=0) - self.assert_fig_hash('4f3676a65cde6c268233679e1d0e6207df62764d', fig) + fig = self.plot.network().node("a").consumption("load").gaussian(scn=0) + self.assert_fig_hash("4f3676a65cde6c268233679e1d0e6207df62764d", fig) def test_production(self): - fig = self.plot.network().node('b').production('nuclear').timeline() - self.assert_fig_hash('33baf5d01fda12b6a2d025abf8421905fc24abe1', fig) + fig = self.plot.network().node("b").production("nuclear").timeline() + self.assert_fig_hash("33baf5d01fda12b6a2d025abf8421905fc24abe1", fig) - fig = self.plot.network().node('b').production('nuclear').monotone(t=0) - self.assert_fig_hash('e059878aac45330810578482df8c3d19261f7f75', fig) + fig = self.plot.network().node("b").production("nuclear").monotone(t=0) + self.assert_fig_hash("e059878aac45330810578482df8c3d19261f7f75", fig) - fig = self.plot.network().node('b').production('nuclear').gaussian(t=0) + fig = self.plot.network().node("b").production("nuclear").gaussian(t=0) # Fail devops self.assert_fig_hash('45ffe15df1d72829ebe2283c9c4b65ee8465c978', fig) def test_link(self): - fig = self.plot.network().node('a').link('b').timeline() - self.assert_fig_hash('97f413ea2fa9908abebf381ec588a7e60b906884', fig) + fig = self.plot.network().node("a").link("b").timeline() + self.assert_fig_hash("97f413ea2fa9908abebf381ec588a7e60b906884", fig) - fig = self.plot.network().node('a').link('b').monotone(scn=0) - self.assert_fig_hash('08b0e0d8414bee2c5083a298af00fe86d0eba6b0', fig) + fig = self.plot.network().node("a").link("b").monotone(scn=0) + self.assert_fig_hash("08b0e0d8414bee2c5083a298af00fe86d0eba6b0", fig) - fig = self.plot.network().node('a').link('b').gaussian(scn=0) - self.assert_fig_hash('5151ade23440beeea9ff144245f81b057c0fa2cd', fig) + fig = self.plot.network().node("a").link("b").gaussian(scn=0) + self.assert_fig_hash("5151ade23440beeea9ff144245f81b057c0fa2cd", fig) def test_storage(self): - study = Study(horizon=4)\ - .network()\ - .node('a')\ - .production(name='nuclear', cost=20, quantity=[10, 10, 10, 0]) \ - .node('b')\ - .consumption(name='load', cost=10 ** 6, quantity=[20, 10, 0, 10]) \ - .storage(name='cell', capacity=30, flow_in=10, flow_out=10, init_capacity=15, eff=.5) \ - .link(src='a', dest='b', cost=1, quantity=10)\ + study = ( + Study(horizon=4) + .network() + .node("a") + .production(name="nuclear", cost=20, quantity=[10, 10, 10, 0]) + .node("b") + .consumption(name="load", cost=10 ** 6, quantity=[20, 10, 0, 10]) + .storage( + name="cell", + capacity=30, + flow_in=10, + flow_out=10, + init_capacity=15, + eff=0.5, + ) + .link(src="a", dest="b", cost=1, quantity=10) .build() + ) optimizer = LPOptimizer() res = optimizer.solve(study) - plot = HTMLPlotting(agg=ResultAnalyzer(study, res), unit_symbol='MW', time_start='2020-02-01', time_end='2020-02-02') + plot = HTMLPlotting( + agg=ResultAnalyzer(study, res), + unit_symbol="MW", + time_start="2020-02-01", + time_end="2020-02-02", + ) - fig = plot.network().node('b').stack() - self.assert_fig_hash('94760e8b7d07704cfe4132a918b4075f5f594d69', fig) + fig = plot.network().node("b").stack() + self.assert_fig_hash("94760e8b7d07704cfe4132a918b4075f5f594d69", fig) - fig = plot.network().node('b').storage('cell').candles(scn=0) - self.assert_fig_hash('594ae603876c2d1bc91899e89d6de50bf37071ee', fig) + fig = plot.network().node("b").storage("cell").candles(scn=0) + self.assert_fig_hash("594ae603876c2d1bc91899e89d6de50bf37071ee", fig) - fig = plot.network().node('b').storage('cell').monotone(scn=0) - self.assert_fig_hash('f020d7954b2fa2245001a4b34530d65ddbd87382', fig) + fig = plot.network().node("b").storage("cell").monotone(scn=0) + self.assert_fig_hash("f020d7954b2fa2245001a4b34530d65ddbd87382", fig) def test_converter(self): - study = Study(horizon=2)\ - .network('elec')\ - .node('a')\ - .consumption(name='load', cost=10**6, quantity=[10, 30])\ - .network('gas')\ - .node('b')\ - .production(name='central', cost=10, quantity=50)\ - .to_converter(name='conv', ratio=0.8)\ - .network('coat')\ - .node('c')\ - .production(name='central', cost=10, quantity=60)\ - .to_converter(name='conv', ratio=0.5)\ - .converter(name='conv', to_network='elec', to_node='a', max=50)\ + study = ( + Study(horizon=2) + .network("elec") + .node("a") + .consumption(name="load", cost=10 ** 6, quantity=[10, 30]) + .network("gas") + .node("b") + .production(name="central", cost=10, quantity=50) + .to_converter(name="conv", ratio=0.8) + .network("coat") + .node("c") + .production(name="central", cost=10, quantity=60) + .to_converter(name="conv", ratio=0.5) + .converter(name="conv", to_network="elec", to_node="a", max=50) .build() + ) optim = LPOptimizer() res = optim.solve(study) - plot = HTMLPlotting(agg=ResultAnalyzer(study, res), unit_symbol='MW', time_start='2020-02-01', - time_end='2020-02-02') + plot = HTMLPlotting( + agg=ResultAnalyzer(study, res), + unit_symbol="MW", + time_start="2020-02-01", + time_end="2020-02-02", + ) - fig = plot.network('elec').node('a').stack() - self.assert_fig_hash('0969b8b1bde6695a4c8cc78fdc5a42928f7af956', fig) + fig = plot.network("elec").node("a").stack() + self.assert_fig_hash("0969b8b1bde6695a4c8cc78fdc5a42928f7af956", fig) - fig = plot.network('gas').node('b').stack() - self.assert_fig_hash('d9a5c9f13c932048f1bcb22ec849a7a4e79b577b', fig) + fig = plot.network("gas").node("b").stack() + self.assert_fig_hash("d9a5c9f13c932048f1bcb22ec849a7a4e79b577b", fig) - fig = plot.network('elec').node('a').from_converter('conv').timeline() - self.assert_fig_hash('5a42ce7a62c12c092631f0a9b63f807ada94ed79', fig) + fig = plot.network("elec").node("a").from_converter("conv").timeline() + self.assert_fig_hash("5a42ce7a62c12c092631f0a9b63f807ada94ed79", fig) - fig = plot.network('gas').node('b').to_converter('conv').timeline() - self.assert_fig_hash('77de14a806dff91a118d395b3e0d998335d64cd7', fig) + fig = plot.network("gas").node("b").to_converter("conv").timeline() + self.assert_fig_hash("77de14a806dff91a118d395b3e0d998335d64cd7", fig) - fig = plot.network('gas').node('b').to_converter('conv').monotone(scn=0) - self.assert_fig_hash('3f6ac9f5e1c8ca611d39b7c62f527e4bfd5a573a', fig) + fig = plot.network("gas").node("b").to_converter("conv").monotone(scn=0) + self.assert_fig_hash("3f6ac9f5e1c8ca611d39b7c62f527e4bfd5a573a", fig) - fig = plot.network('elec').node('a').from_converter('conv').gaussian(scn=0) - self.assert_fig_hash('32a6e175600822c833a9b7f3008aa35230b0b646', fig) + fig = plot.network("elec").node("a").from_converter("conv").gaussian(scn=0) + self.assert_fig_hash("32a6e175600822c833a9b7f3008aa35230b0b646", fig) def assert_fig_hash(self, expected: str, fig: go.Figure): # if sys.platform != 'darwin' or (ma, mi) != (3, 7): # We only test graphics for MacOS, there are little change with other distrib @@ -159,8 +186,10 @@ def assert_fig_hash(self, expected: str, fig: go.Figure): @staticmethod def get_html(fig: go.Figure) -> bytes: - html = plot(fig, include_plotlyjs=False, include_mathjax=False, output_type='div') + html = plot( + fig, include_plotlyjs=False, include_mathjax=False, output_type="div" + ) # plotly use a random id. We need to extract it and replace it by constant # uuid can be find at ...
pd.DataFrame: class Divide(FocusStage): def __init__(self): - Stage.__init__(self, RestrictedPlug(inputs=['a', 'b'], outputs=['d', 'r'])) + Stage.__init__(self, RestrictedPlug(inputs=["a", "b"], outputs=["d", "r"])) def _process_scenarios(self, n_scn: int, scenario: pd.DataFrame) -> pd.DataFrame: - scenario.loc[:, 'd'] = (scenario['a'] / scenario['b']).apply(np.floor) - scenario.loc[:, 'r'] = scenario['a'] - scenario['b'] * scenario['d'] - return scenario.drop(['a', 'b'], axis=1) + scenario.loc[:, "d"] = (scenario["a"] / scenario["b"]).apply(np.floor) + scenario.loc[:, "r"] = scenario["a"] - scenario["b"] * scenario["d"] + return scenario.drop(["a", "b"], axis=1) class Inverse(FocusStage): def __init__(self): - FocusStage.__init__(self, RestrictedPlug(inputs=['d'], outputs=['d', '-d'])) + FocusStage.__init__(self, RestrictedPlug(inputs=["d"], outputs=["d", "-d"])) def _process_scenarios(self, n_scn: int, scenario: pd.DataFrame) -> pd.DataFrame: - scenario.loc[:, '-d'] = -scenario['d'] + scenario.loc[:, "-d"] = -scenario["d"] return scenario.copy() class Wrong(Stage): def __init__(self): - Stage.__init__(self, plug=RestrictedPlug(inputs=['e'], outputs=['e'])) + Stage.__init__(self, plug=RestrictedPlug(inputs=["e"], outputs=["e"])) def _process_timeline(self, timeline: pd.DataFrame) -> pd.DataFrame: return timeline @@ -74,7 +84,7 @@ def test_join_to_fre(self): def test_join_to_restricted(self): # Input a = FreePlug() - b = RestrictedPlug(inputs=['a', 'b'], outputs=['c', 'd']) + b = RestrictedPlug(inputs=["a", "b"], outputs=["c", "d"]) # Test c = a + b @@ -84,30 +94,30 @@ def test_join_to_restricted(self): class TestRestrictedPlug(unittest.TestCase): def test_linkable_to_free(self): # Input - a = RestrictedPlug(inputs=['a'], outputs=['b']) + a = RestrictedPlug(inputs=["a"], outputs=["b"]) # Test self.assertTrue(a.linkable_to(FreePlug())) def test_linkable_to_restricted_ok(self): # Input - a = RestrictedPlug(inputs=['a'], outputs=['b', 'c', 'd']) - b = RestrictedPlug(inputs=['b', 'c'], outputs=['e']) + a = RestrictedPlug(inputs=["a"], outputs=["b", "c", "d"]) + b = RestrictedPlug(inputs=["b", "c"], outputs=["e"]) # Test self.assertTrue(a.linkable_to(b)) def test_linkable_to_restricted_wrong(self): # Input - a = RestrictedPlug(inputs=['a'], outputs=['b', 'c', 'd']) - b = RestrictedPlug(inputs=['b', 'c', 'f'], outputs=['e']) + a = RestrictedPlug(inputs=["a"], outputs=["b", "c", "d"]) + b = RestrictedPlug(inputs=["b", "c", "f"], outputs=["e"]) # Test self.assertFalse(a.linkable_to(b)) def test_join_to_free(self): # Input - a = RestrictedPlug(inputs=['a'], outputs=['b']) + a = RestrictedPlug(inputs=["a"], outputs=["b"]) # Test b = a + FreePlug() @@ -115,11 +125,11 @@ def test_join_to_free(self): def test_join_to_restricted(self): # Input - a = RestrictedPlug(inputs=['a'], outputs=['b', 'c', 'd']) - b = RestrictedPlug(inputs=['b', 'c'], outputs=['e']) + a = RestrictedPlug(inputs=["a"], outputs=["b", "c", "d"]) + b = RestrictedPlug(inputs=["b", "c"], outputs=["e"]) # Expected - exp = RestrictedPlug(inputs=['a'], outputs=['e', 'd']) + exp = RestrictedPlug(inputs=["a"], outputs=["e", "d"]) # Test c = a + b @@ -129,11 +139,11 @@ def test_join_to_restricted(self): class TestPipeline(unittest.TestCase): def test_compute(self): # Input - i = pd.DataFrame({'a': [1, 2, 3]}) + i = pd.DataFrame({"a": [1, 2, 3]}) pipe = Pipeline(stages=[Double(), Double()]) # Expected - exp = pd.DataFrame({(0, 'a'): [4, 8, 12]}) + exp = pd.DataFrame({(0, "a"): [4, 8, 12]}) # Test & Verify o = pipe(i) @@ -141,12 +151,12 @@ def test_compute(self): def test_add(self): # Input - i = pd.DataFrame({'a': [1, 2, 3], 'b': [1, 2, 3]}) + i = pd.DataFrame({"a": [1, 2, 3], "b": [1, 2, 3]}) pipe = Pipeline(stages=[Double(), Double()]) pipe += Divide() # Expected - exp = pd.DataFrame({(0, 'd'): [1, 1, 1], (0, 'r'): [0, 0, 0]}, dtype=float) + exp = pd.DataFrame({(0, "d"): [1, 1, 1], (0, "r"): [0, 0, 0]}, dtype=float) # Test & Verify o = pipe(i) @@ -156,11 +166,11 @@ def test_add(self): def test_link_pipeline_free_to_free(self): # Input - i = pd.DataFrame({'a': [1, 2, 3], 'b': [4, 5, 6]}) + i = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}) pipe = Double() + Max9() # Expected - exp = pd.DataFrame({(0, 'a'): [2, 4, 6], (0, 'b'): [8, 9, 9]}) + exp = pd.DataFrame({(0, "a"): [2, 4, 6], (0, "b"): [8, 9, 9]}) # Test & Verify o = pipe(i) @@ -170,45 +180,48 @@ def test_link_pipeline_free_to_free(self): def test_link_pipeline_free_to_restricted(self): # Input - i = pd.DataFrame({'a': [10, 20, 32], 'b': [4, 5, 6]}) + i = pd.DataFrame({"a": [10, 20, 32], "b": [4, 5, 6]}) pipe = Double() + Divide() # Expected - exp = pd.DataFrame({(0, 'd'): [2, 4, 5], (0, 'r'): [4, 0, 4]}, dtype='float') + exp = pd.DataFrame({(0, "d"): [2, 4, 5], (0, "r"): [4, 0, 4]}, dtype="float") # Test & Verify o = pipe(i) pd.testing.assert_frame_equal(exp, o) - self.assertEqual(['a', 'b'], pipe.plug.inputs) - self.assertEqual(['d', 'r'], pipe.plug.outputs) + self.assertEqual(["a", "b"], pipe.plug.inputs) + self.assertEqual(["d", "r"], pipe.plug.outputs) def test_link_pipeline_restricted_to_free(self): # Input - i = pd.DataFrame({'a': [10, 20, 32], 'b': [4, 5, 6]}) + i = pd.DataFrame({"a": [10, 20, 32], "b": [4, 5, 6]}) pipe = Divide() + Double() # Expected - exp = pd.DataFrame({(0, 'd'): [4, 8, 10], (0, 'r'): [4, 0, 4]}, dtype='float') + exp = pd.DataFrame({(0, "d"): [4, 8, 10], (0, "r"): [4, 0, 4]}, dtype="float") # Test & Verify o = pipe(i) pd.testing.assert_frame_equal(exp, o) - self.assertEqual(['a', 'b'], pipe.plug.inputs) - self.assertEqual(['d', 'r'], pipe.plug.outputs) + self.assertEqual(["a", "b"], pipe.plug.inputs) + self.assertEqual(["d", "r"], pipe.plug.outputs) def test_link_pipeline_restricted_to_restricted(self): # Input - i = pd.DataFrame({'a': [10, 20, 32], 'b': [4, 5, 6]}) + i = pd.DataFrame({"a": [10, 20, 32], "b": [4, 5, 6]}) pipe = Divide() + Inverse() # Expected - exp = pd.DataFrame({(0, 'd'): [2, 4, 5], (0, '-d'): [-2, -4, -5], (0, 'r'): [2, 0, 2]}, dtype='float') + exp = pd.DataFrame( + {(0, "d"): [2, 4, 5], (0, "-d"): [-2, -4, -5], (0, "r"): [2, 0, 2]}, + dtype="float", + ) # Test & Verify o = pipe(i) pd.testing.assert_frame_equal(exp, o) - self.assertEqual({'a', 'b'}, set(pipe.plug.inputs)) - self.assertEqual({'d', '-d', 'r'}, set(pipe.plug.outputs)) + self.assertEqual({"a", "b"}, set(pipe.plug.inputs)) + self.assertEqual({"d", "-d", "r"}, set(pipe.plug.outputs)) def test_wrong_link(self): # Test & Verify @@ -218,29 +231,41 @@ def test_wrong_link(self): class TestStage(unittest.TestCase): def test_compute(self): # Input - i = pd.DataFrame({(0, 'a'): [1, 2, 3], (0, 'b'): [4, 5, 6], - (1, 'a'): [10, 20, 30], (1, 'b'): [40, 50, 60]}) + i = pd.DataFrame( + { + (0, "a"): [1, 2, 3], + (0, "b"): [4, 5, 6], + (1, "a"): [10, 20, 30], + (1, "b"): [40, 50, 60], + } + ) stage = Double() # Expected - exp = pd.DataFrame({(0, 'a'): [2, 4, 6], (0, 'b'): [8, 10, 12], - (1, 'a'): [20, 40, 60], (1, 'b'): [80, 100, 120]}) + exp = pd.DataFrame( + { + (0, "a"): [2, 4, 6], + (0, "b"): [8, 10, 12], + (1, "a"): [20, 40, 60], + (1, "b"): [80, 100, 120], + } + ) # Test & Verify o = stage(i) pd.testing.assert_frame_equal(exp, o) def test_wrong_compute(self): - i = pd.DataFrame({'a': [1, 2, 3], 'b': [4, 5, 6]}) + i = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}) pipe = Inverse() self.assertRaises(ValueError, lambda: pipe(i)) def test_standardize_column(self): - i = pd.DataFrame({'a': [1, 2, 3]}) + i = pd.DataFrame({"a": [1, 2, 3]}) # Expected - exp = pd.DataFrame({(0, 'a'): [1, 2, 3]}) + exp = pd.DataFrame({(0, "a"): [1, 2, 3]}) res = Stage.standardize_column(i) pd.testing.assert_frame_equal(exp, res) @@ -248,10 +273,12 @@ def test_standardize_column(self): def test_build_multi_index(self): # Input scenarios = [1, 2, 3] - names = ['a', 'b'] + names = ["a", "b"] # Expected - exp = MultiIndex.from_tuples([(1, 'a'), (1, 'b'), (2, 'a'), (2, 'b'), (3, 'a'), (3, 'b')]) + exp = MultiIndex.from_tuples( + [(1, "a"), (1, "b"), (2, "a"), (2, "b"), (3, "a"), (3, "b")] + ) # Test & Verify index = Stage.build_multi_index(scenarios=scenarios, names=names) @@ -261,13 +288,26 @@ def test_build_multi_index(self): class TestFocusPipeline(unittest.TestCase): def test_compute(self): # Input - i = pd.DataFrame({(0, 'b'): [1, 2, 3], (0, 'a'): [4, 5, 6], - (1, 'b'): [10, 20, 30], (1, 'a'): [40, 50, 60]}) + i = pd.DataFrame( + { + (0, "b"): [1, 2, 3], + (0, "a"): [4, 5, 6], + (1, "b"): [10, 20, 30], + (1, "a"): [40, 50, 60], + } + ) pipe = Divide() # Expected - exp = pd.DataFrame({(0, 'd'): [4, 2, 2], (0, 'r'): [0, 1, 0], - (1, 'd'): [4, 2, 2], (1, 'r'): [0, 10, 0]}, dtype='float') + exp = pd.DataFrame( + { + (0, "d"): [4, 2, 2], + (0, "r"): [0, 1, 0], + (1, "d"): [4, 2, 2], + (1, "r"): [0, 10, 0], + }, + dtype="float", + ) # Test & Verify o = pipe(i) @@ -277,12 +317,12 @@ def test_compute(self): class TestClip(unittest.TestCase): def test_compute(self): # Input - i = pd.DataFrame({'a': [12, 54, 87, 12], 'b': [98, 23, 65, 4]}) + i = pd.DataFrame({"a": [12, 54, 87, 12], "b": [98, 23, 65, 4]}) pipe = Clip(lower=10, upper=50) # Expected - exp = pd.DataFrame({(0, 'a'): [12, 50, 50, 12], (0, 'b'): [50, 23, 50, 10]}) + exp = pd.DataFrame({(0, "a"): [12, 50, 50, 12], (0, "b"): [50, 23, 50, 10]}) # Test & Verify o = pipe(i) @@ -292,12 +332,12 @@ def test_compute(self): class TestRename(unittest.TestCase): def test_compute(self): # Input - i = pd.DataFrame({'a': [12, 54, 87, 12], 'b': [98, 23, 65, 4]}) + i = pd.DataFrame({"a": [12, 54, 87, 12], "b": [98, 23, 65, 4]}) - pipe = Rename(a='alpha') + pipe = Rename(a="alpha") # Expected - exp = pd.DataFrame({(0, 'alpha'): [12, 54, 87, 12], (0, 'b'): [98, 23, 65, 4]}) + exp = pd.DataFrame({(0, "alpha"): [12, 54, 87, 12], (0, "b"): [98, 23, 65, 4]}) # Test & Verify o = pipe(i) @@ -307,12 +347,12 @@ def test_compute(self): class TestDrop(unittest.TestCase): def test_compute(self): # Input - i = pd.DataFrame({'a': [12, 54, 87, 12], 'b': [98, 23, 65, 4]}) + i = pd.DataFrame({"a": [12, 54, 87, 12], "b": [98, 23, 65, 4]}) - pipe = Drop('b') + pipe = Drop("b") # Expected - exp = pd.DataFrame({(0, 'a'): [12, 54, 87, 12]}) + exp = pd.DataFrame({(0, "a"): [12, 54, 87, 12]}) # Test & Verify o = pipe(i) @@ -323,42 +363,60 @@ class TestFault(unittest.TestCase): def test_compute(self): # Input power = 100 - i = pd.DataFrame({'quantity': np.ones(10000) * power}) + i = pd.DataFrame({"quantity": np.ones(10000) * power}) - pipe = Fault(loss=20, occur_freq=0.001, downtime_min=50, downtime_max=60, seed=543) + pipe = Fault( + loss=20, occur_freq=0.001, downtime_min=50, downtime_max=60, seed=543 + ) # Expected - exp_time_down = i.size * pipe.occur_freq * (pipe.downtime_max + pipe.downtime_min) / 2 + exp_time_down = ( + i.size * pipe.occur_freq * (pipe.downtime_max + pipe.downtime_min) / 2 + ) exp_total_loss = exp_time_down * pipe.loss # Test & Verify o = pipe(i) time_down = o.where(o < power).dropna().size - self.assertAlmostEqual(exp_time_down, time_down, delta=exp_time_down*0.1) + self.assertAlmostEqual(exp_time_down, time_down, delta=exp_time_down * 0.1) total_loss = o.size * power - o.values.sum() - self.assertAlmostEqual(exp_total_loss, total_loss, delta=exp_total_loss*0.1) + self.assertAlmostEqual(exp_total_loss, total_loss, delta=exp_total_loss * 0.1) class TestRepeat(unittest.TestCase): def test_compute(self): # Input - i = pd.DataFrame({'a': [12, 54, 87, 12], 'b': [98, 23, 65, 4]}) + i = pd.DataFrame({"a": [12, 54, 87, 12], "b": [98, 23, 65, 4]}) pipe = RepeatScenario(n=2) # Expected - exp1 = pd.DataFrame({(0, 'a'): [12, 54, 87, 12], (0, 'b'): [98, 23, 65, 4], - (1, 'a'): [12, 54, 87, 12], (1, 'b'): [98, 23, 65, 4]}) - - exp2 = pd.DataFrame({(0, 'a'): [12, 54, 87, 12], (0, 'b'): [98, 23, 65, 4], - (1, 'a'): [12, 54, 87, 12], (1, 'b'): [98, 23, 65, 4], - (2, 'a'): [12, 54, 87, 12], (2, 'b'): [98, 23, 65, 4], - (3, 'a'): [12, 54, 87, 12], (3, 'b'): [98, 23, 65, 4]}) + exp1 = pd.DataFrame( + { + (0, "a"): [12, 54, 87, 12], + (0, "b"): [98, 23, 65, 4], + (1, "a"): [12, 54, 87, 12], + (1, "b"): [98, 23, 65, 4], + } + ) + + exp2 = pd.DataFrame( + { + (0, "a"): [12, 54, 87, 12], + (0, "b"): [98, 23, 65, 4], + (1, "a"): [12, 54, 87, 12], + (1, "b"): [98, 23, 65, 4], + (2, "a"): [12, 54, 87, 12], + (2, "b"): [98, 23, 65, 4], + (3, "a"): [12, 54, 87, 12], + (3, "b"): [98, 23, 65, 4], + } + ) # Test & Verify o = pipe(i) pd.testing.assert_frame_equal(exp1, o) o = pipe(o) - pd.testing.assert_frame_equal(exp2, o) \ No newline at end of file + pd.testing.assert_frame_equal(exp2, o) diff --git a/tests/workflow/test_shuffler.py b/tests/workflow/test_shuffler.py index 105b8c7..0a15473 100644 --- a/tests/workflow/test_shuffler.py +++ b/tests/workflow/test_shuffler.py @@ -23,7 +23,7 @@ def range_sampler(low, high, size): class MockPipeline(Pipeline): def __init__(self, return_value): - Pipeline.__init__(self, stages=[ToShuffler('')]) + Pipeline.__init__(self, stages=[ToShuffler("")]) self.return_value = return_value self.input = None @@ -44,13 +44,17 @@ def test_sample(self): tl = Timeline(np.arange(0, 15).reshape(5, 3), sampler=range_sampler) # Expected - exp = np.array([[0, 1, 2], - [3, 4, 5], - [6, 7, 8], - [9, 10, 11], - [12, 13, 14], - [0, 1, 2], - [3, 4, 5]]) + exp = np.array( + [ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [9, 10, 11], + [12, 13, 14], + [0, 1, 2], + [3, 4, 5], + ] + ) # Test & Verify res = tl.sample(7) @@ -63,14 +67,19 @@ def test_compute(self): i = pd.DataFrame() # Mock - o = pd.DataFrame({(0, TO_SHUFFLER): [1, 2, 3], (0, 'b'): [4, 5, 6], - (1, TO_SHUFFLER): [10, 20, 30], (1, 'b'): [40, 50, 60]}) + o = pd.DataFrame( + { + (0, TO_SHUFFLER): [1, 2, 3], + (0, "b"): [4, 5, 6], + (1, TO_SHUFFLER): [10, 20, 30], + (1, "b"): [40, 50, 60], + } + ) mock_pipe = MockPipeline(return_value=o) # Expected - exp = np.array([[1, 2, 3], - [10, 20, 30]]) + exp = np.array([[1, 2, 3], [10, 20, 30]]) # Test & Verify tl = TimelinePipeline(i, mock_pipe) @@ -83,21 +92,27 @@ class TestShuffler(unittest.TestCase): def test_shuffle(self): # Input shuffler = Shuffler(sampler=range_sampler) - shuffler.add_data(name='solar', data=np.array([[1, 2, 3], [5, 6, 7]])) + shuffler.add_data(name="solar", data=np.array([[1, 2, 3], [5, 6, 7]])) - i = pd.DataFrame({(0, 'a'): [3, 4, 5], (1, 'a'): [7, 8, 9]}) + i = pd.DataFrame({(0, "a"): [3, 4, 5], (1, "a"): [7, 8, 9]}) # Mock - o = pd.DataFrame({(0, TO_SHUFFLER): [3, 4, 5], - (1, TO_SHUFFLER): [7, 8, 9], - (0, TO_SHUFFLER): [3, 4, 5], - (1, TO_SHUFFLER): [7, 8, 9]}) + o = pd.DataFrame( + { + (0, TO_SHUFFLER): [3, 4, 5], + (1, TO_SHUFFLER): [7, 8, 9], + (0, TO_SHUFFLER): [3, 4, 5], + (1, TO_SHUFFLER): [7, 8, 9], + } + ) mock_pipe = MockPipeline(return_value=o) - shuffler.add_pipeline(name='load', data=i, pipeline=mock_pipe) + shuffler.add_pipeline(name="load", data=i, pipeline=mock_pipe) # Expected - exp = {'solar': np.array([[1, 2, 3], [5, 6, 7], [1, 2, 3]]), - 'load': np.array([[3, 4, 5], [7, 8, 9], [3, 4, 5]])} + exp = { + "solar": np.array([[1, 2, 3], [5, 6, 7], [1, 2, 3]]), + "load": np.array([[3, 4, 5], [7, 8, 9], [3, 4, 5]]), + } # Test & Verify res = shuffler.shuffle(3)