Source code for biosteam.process_tools.unit_group

# -*- coding: utf-8 -*-
# BioSTEAM: The Biorefinery Simulation and Techno-Economic Analysis Modules
# Copyright (C) 2020-2023, Yoel Cortes-Pena <yoelcortes@gmail.com>
# 
# This module is under the UIUC open-source license. See 
# github.com/BioSTEAMDevelopmentGroup/biosteam/blob/master/LICENSE.txt
# for license details.

import pandas as pd
import biosteam as bst
import numpy as np
from thermosteam.utils import repr_items
from . import utils
from .._unit import Unit
from ..utils import streams_from_units
from .._heat_utility import HeatUtility
from collections.abc import Mapping

__all__ = ('UnitGroup', 'GroupedUnit', 'create_connected_grouped_units')

INST_EQ_COST = 'Inst. eq. cost'
ELEC_CONS = 'Elec. cons.'
ELEC_PROD = 'Elec. prod.'
INSTALLED_EQUIPMENT_COST = 'Installed equipment cost'
COOLING = 'Cooling'
HEATING = 'Heating'
COOLING_DUTY = 'Cooling duty'
HEATING_DUTY = 'Heating duty'
ELECTRICITY_CONSUMPTION = 'Electricity consumption'
ELECTRICITY_PRODUCTION = 'Electricity production'
MATERIAL_COST = 'Material cost'
MAT_COST = 'Mat. cost'
CAPITAL_UNITS = 'MM$'
ELEC_UNITS = 'MW'
DUTY_UNITS = 'GJ/hr'
MAT_UNITS = 'USD/hr'

# %% Unit group for generating results

[docs] class UnitGroup: """ Create a UnitGroup object for generating biorefinery results. Parameters ---------- name : str, optional Name of group for bookkeeping. units : tuple[Unit], optional Unit operations. metrics=None : list[Metric], optional Metrics to generate results. These metrics are computed when generating results as dictionaries, pandas series, and data frames. Examples -------- Create a UnitGroup from BioSTEAM's example ethanol subsystem: >>> from biorefineries.sugarcane import chemicals >>> from biosteam import * >>> settings.set_thermo(chemicals) >>> water = Stream('water', Water=100., T=350.) >>> sucrose = Stream('sucrose', Sucrose=3.) >>> with System('example_sys') as example_sys: ... P1 = Pump('P1', water) ... T1 = MixTank('T1', (P1-0, sucrose)) ... H1 = HXutility('H1', T1-0, T=300.) >>> example_sys.simulate() >>> ugroup = UnitGroup('Example group', example_sys.units) We can autofill metrics to evaluate: >>> ugroup.autofill_metrics(electricity_production=True) >>> ugroup.metrics [<Metric: Installed equipment cost (MM$)>, <Metric: Cooling duty (GJ/hr)>, <Metric: Heating duty (GJ/hr)>, <Metric: Electricity consumption (MW)>, <Metric: Electricity production (MW)>, <Metric: Material cost (USD/hr)>] Get all metric results: >>> ugroup.to_dict() {'Installed equipment cost [MM$]': 0.05, 'Cooling duty [GJ/hr]': 0.37, 'Heating duty [GJ/hr]': 0.0, 'Electricity consumption [MW]': 0.0, 'Electricity production [MW]': 0.0, 'Material cost [USD/hr]': 0.0} Each result can be retrieved separately: >>> ugroup.get_installed_cost() 0.05 >>> ugroup.get_cooling_duty() 0.37 The `to_dict` method also returns user-defined metrics: >>> # First define metrics >>> @ugroup.metric # Name of metric defaults to function name ... def moisture_content(): ... product = H1.outs[0] ... return product.imass['Water'] / product.F_mass >>> @ugroup.metric(units='kg/hr') # This helps for bookkeeping ... def sucrose_flow_rate(): ... return float(H1.outs[0].imass['Sucrose']) >>> ugroup.show() UnitGroup: Example group units: P1, T1, H1 metrics: Installed equipment cost Cooling duty Heating duty Electricity consumption Electricity production Material cost Moisture content Sucrose flow rate >>> ugroup.to_dict() {'Installed equipment cost [MM$]': 0.05, 'Cooling duty [GJ/hr]': 0.37, 'Heating duty [GJ/hr]': 0.0, 'Electricity consumption [MW]': 0.0, 'Electricity production [MW]': 0.0, 'Material cost [USD/hr]': 0.0, 'Moisture content': 0.63, 'Sucrose flow rate [kg/hr]': 1026.8} """ __slots__ = ('name', 'units', 'metrics', 'filter_savings', 'extend_feed_ends') def __init__(self, name=None, units=(), metrics=None, filter_savings=True, extend_feed_ends=True): #: [str] Name of group for bookkeeping self.name = 'Unnamed' if name is None else str(name) #: list[Unit] Unit operations self.units = units if isinstance(units, list) else list(units) if metrics is None: metrics = [] elif not isinstance(metrics, list): metrics = list(metrics) #: list[Metric] Metrics to generate results self.metrics = metrics #: [bool] Whether to only allow postive flows in utility results self.filter_savings = filter_savings #: [bool] Whether to consider feeds past external storage, pumps, and heat #: exchangers for calculating material costs. self.extend_feed_ends = extend_feed_ends def autofill_metrics(self, shorthand=False, installed_cost=True, cooling_duty=True, heating_duty=True, electricity_consumption=True, electricity_production=False, material_cost=True): if installed_cost: self.metric(self.get_installed_cost, INST_EQ_COST if shorthand else INSTALLED_EQUIPMENT_COST, CAPITAL_UNITS) if cooling_duty: self.metric(self.get_cooling_duty, COOLING if shorthand else COOLING_DUTY, DUTY_UNITS) if heating_duty: self.metric(self.get_heating_duty, HEATING if shorthand else HEATING_DUTY, DUTY_UNITS) if electricity_consumption: self.metric(self.get_electricity_consumption, ELEC_CONS if shorthand else ELECTRICITY_CONSUMPTION, ELEC_UNITS) if electricity_production: self.metric(self.get_electricity_production, ELEC_PROD if shorthand else ELECTRICITY_PRODUCTION, ELEC_UNITS) if material_cost: self.metric(self.get_material_cost, MAT_COST if shorthand else MATERIAL_COST, MAT_UNITS) def __iter__(self): return iter(self.units) def to_unit(self, ID=None, thermo=None): return GroupedUnit(ID, units=self.units)
[docs] def to_system(self, ID=None): """Return a System object of all units.""" return bst.System.from_units(ID, self.units)
[docs] def split(self, stream, upstream_name=None, downstream_name=None): """ Split unit group in two; upstream and downstream. Parameters ---------- stream : Iterable[:class:~thermosteam.Stream], optional Stream where unit group will be split. upstream_name : str, optional Name of upstream UnitGroup object. downstream_name : str, optional Name of downstream UnitGroup object. Examples -------- >>> from biorefineries.cornstover import cornstover_sys, M201 >>> from biosteam import default >>> ugroup = cornstover_sys.to_unit_group() >>> upstream, downstream = ugroup.split(M201-0) >>> upstream.show() UnitGroup: Unnamed units: U101, H2SO4_storage, T201, M201 >>> for i in upstream: assert i not in downstream.units >>> assert set(upstream.units + downstream.units) == set(cornstover_sys.units) >>> default() # Reset to biosteam defaults """ sys = self.to_system() units = sys.units index = units.index(stream.sink) return (UnitGroup(upstream_name, units[:index]), UnitGroup(downstream_name, units[index:]))
[docs] def metric(self, getter=None, name=None, units=None, element=None): """ Define and register metric. Parameters ---------- getter : function, optional Should return metric. name : str, optional Name of parameter. If None, defaults to the name of the getter. units : str, optional Parameter units of measure Notes ----- This method works as a decorator. """ if element is None: element = '' if not getter: return lambda getter: self.metric(getter, name, units, element) metric = bst.Metric(name, getter, units, element) bst.Metric.check_index_unique(metric, self.metrics) self.metrics.append(metric) return metric
[docs] def register_utility_agent(self, agent, basis='duty'): """Register utility agent as a metric to UnitGroup.""" if isinstance(agent, str): agent = HeatUtility.get_agent(agent) name = agent.ID.replace('_', ' ').capitalize() if basis == 'duty': self.metric(lambda: self.get_utility_duty(agent), name, 'GJ') elif basis == 'flow': self.metric(lambda: self.get_utility_flow(agent), name, 'MT') else: raise ValueError(f"basis must be either 'duty' or 'flow', not {repr(basis)}")
@property def heat_utilities(self): """[tuple] All HeatUtility objects.""" heat_utilities = utils.get_heat_utilities(self.units) if self.filter_savings: return utils.filter_out_heat_utility_savings(heat_utilities) else: return heat_utilities @property def power_utilities(self): """[tuple] All PowerUtility objects.""" return tuple(utils.get_power_utilities(self.units))
[docs] @classmethod def filter_by_types(cls, name, units, types): """Create a UnitGroup object of given type(s).""" return cls(name, utils.filter_by_types(units, types))
[docs] @classmethod def filter_by_lines(cls, name, units, lines): """Create a UnitGroup object of given line(s).""" return cls(name, utils.filter_by_lines(units, lines))
[docs] @classmethod def group_by_types(cls, units, name_types=None): """Create a list of UnitGroup objects for each name-type pair.""" if name_types: if isinstance(name_types, Mapping): name_types = name_types.items() return [cls.filter_by_types(name, units, types) for name, types in name_types] else: groups = utils.group_by_types(units) return [cls(i, j) for i, j in groups.items()]
[docs] @classmethod def group_by_lines(cls, units, name_lines=None): """Create a list of UnitGroup objects for each name-line pair.""" if name_lines: if isinstance(name_lines, Mapping): name_lines = name_lines.items() return [cls.filter_by_lines(name, units, lines) for name, lines in name_lines] else: groups = utils.group_by_lines(units) return [cls(i, j) for i, j in groups.items()]
[docs] @classmethod def group_by_area(cls, units): """ Create a list of UnitGroup objects for each area available. Examples -------- >>> from biosteam import * >>> from biorefineries.cornstover import cornstover_sys >>> areas = UnitGroup.group_by_area(cornstover_sys.units) >>> areas[-1].show() UnitGroup: 700 units: T701, P701, T702, P702, M701, T703 >>> default() # Bring biosteam settings back to default """ return [cls(i, j) for i, j in utils.group_by_area(units).items()]
[docs] def get_inlet_flow(self, units, key=None): """ Return total flow across all inlets. Parameters ---------- units : str Units of measure. key : tuple[str] or str, optional Chemical identifiers. If none given, the sum of all chemicals returned Examples -------- >>> from biosteam import Stream, Mixer, Splitter, UnitGroup, settings, main_flowsheet >>> settings.set_thermo(['Water', 'Ethanol']) >>> main_flowsheet.clear() >>> S1 = Splitter('S1', Stream(Ethanol=10, units='ton/hr'), split=0.1) >>> M1 = Mixer('M1', ins=[Stream(Water=10, units='ton/hr'), S1-0]) >>> sys = main_flowsheet.create_system(operating_hours=330*24) >>> ugroup = UnitGroup('Example group', sys.units) >>> ugroup.get_inlet_flow('ton/hr') # Sum of all chemicals 20.0 >>> ugroup.get_inlet_flow('ton/hr', 'Water') # Just water 10.0 """ if key: return sum([i.get_flow(units, key) for i in bst.utils.feeds_from_units(self.units)]) else: return sum([i.get_total_flow(units) for i in bst.utils.feeds_from_units(self.units)])
[docs] def get_outlet_flow(self, units, key=None): """ Return total flow across all outlets. Parameters ---------- units : str Units of measure. key : tuple[str] or str, optional Chemical identifiers. If none given, the sum of all chemicals returned Examples -------- >>> from biosteam import Stream, Mixer, Splitter, UnitGroup, settings, main_flowsheet >>> settings.set_thermo(['Water', 'Ethanol']) >>> main_flowsheet.clear() >>> S1 = Splitter('S1', Stream(Ethanol=10, units='ton/hr'), split=0.1) >>> M1 = Mixer('M1', ins=[Stream(Water=10, units='ton/hr'), S1-0]) >>> sys = main_flowsheet.create_system(operating_hours=330*24) >>> sys.simulate() >>> ugroup = UnitGroup('Example group', sys.units) >>> ugroup.get_inlet_flow('ton/hr') # Sum of all chemicals 20.0 >>> ugroup.get_inlet_flow('ton/hr', 'Water') # Just water 10.0 """ if key: return sum([i.get_flow(units, key) for i in bst.utils.products_from_units(self.units)]) else: return sum([i.get_total_flow(units) for i in bst.utils.products_from_units(self.units)])
[docs] def get_material_cost(self): """Return the total material cost in USD/hr""" inlets = bst.utils.feeds_from_units(self.units) inlets = set(inlets) bst.utils.filter_out_missing_streams(inlets) if self.extend_feed_ends: get_inlet_origin = bst.utils.get_inlet_origin inlets = [get_inlet_origin(i) for i in inlets] feeds = bst.utils.feeds(inlets) return sum([i.cost for i in feeds])
[docs] def get_utility_duty(self, agent): """Return the total utility duty for given agent in GJ/hr""" return utils.get_utility_duty(self.heat_utilities, agent)
[docs] def get_utility_flow(self, agent): """Return the total utility flow for given agent in MT/hr""" return utils.get_utility_flow(self.heat_utilities, agent)
[docs] def get_cooling_duty(self): """Return the total cooling duty in GJ/hr.""" return utils.get_cooling_duty(self.heat_utilities)
[docs] def get_heating_duty(self): """Return the total heating duty in GJ/hr.""" return utils.get_heating_duty(self.heat_utilities)
[docs] def get_installed_cost(self): """Return the total installed equipment cost in million USD.""" return utils.get_installed_cost(self.units)
[docs] def get_purchase_cost(self): """Return the total equipment purchase cost in million USD.""" return utils.get_purchase_cost(self.units)
[docs] def get_electricity_consumption(self): """Return the total electricity consumption in MW.""" return utils.get_electricity_consumption(self.power_utilities)
[docs] def get_electricity_production(self): """Return the total electricity production in MW.""" return utils.get_electricity_production(self.power_utilities)
[docs] def get_net_electricity_production(self): """Return the net electricity production in MW.""" power_utilities = self.power_utilities return (utils.get_electricity_production(power_utilities) - utils.get_electricity_consumption(power_utilities))
[docs] def to_dict(self, with_units=True): """Return dictionary of results.""" metrics = self.metrics if not metrics: self.autofill_metrics() if with_units: return {i.name_with_units: i() for i in self.metrics} else: return {i.name: i() for i in self.metrics}
def diagram(self, *args, **kwargs): return bst.System(None, self.units).diagram(*args, **kwargs)
[docs] def to_series(self, with_units=True): """Return a pandas.Series object of metric results.""" return pd.Series(self.to_dict(with_units), name=self.name)
[docs] @classmethod def df_from_groups(cls, unit_groups, fraction=False, scale_fractions_to_positive_values=True): """ Return a pandas DataFrame object of metric results from unit groups. Parameters ---------- unit_groups : Sequence[UnitGroup] Metric results will be calculated from unit groups. fraction : bool, optional. Whether to divide metric results by the total sum across all groups. scale_fractions_to_positive_values : bool, optional. Whether to compute fractions by dividing results by the sum of only positive results. s Examples -------- Create a pandas DataFrame of the net eletricity production across all areas in the sugarcane biorefinery: >>> import biosteam as bst >>> from biorefineries import sugarcane as sc >>> sc.load() >>> unit_groups = bst.UnitGroup.group_by_area(sc.sys.units) >>> for i in unit_groups: ... metric = i.metric(i.get_net_electricity_production, ... 'Net electricity production', 'kW') >>> bst.UnitGroup.df_from_groups( ... unit_groups, fraction=True, ... scale_fractions_to_positive_values=True, ... ) Net electricity production 0 100 100 -2.97 200 -3.5 300 -0.907 400 0 >>> bst.UnitGroup.df_from_groups( ... unit_groups, fraction=True, ... scale_fractions_to_positive_values=False, ... ) Net electricity production 0 108 100 -3.21 200 -3.78 300 -0.98 400 0 >>> bst.default() # Reset to biosteam defaults """ with_units = not fraction data = [i.to_series(with_units) for i in unit_groups] df = pd.DataFrame(data) if fraction: values = df.values if scale_fractions_to_positive_values: postive_values = np.where(values > 0., values, 0.) values *= 100 / postive_values.sum(axis=0, keepdims=True) else: values *= 100 / values.sum(axis=0, keepdims=True) return df
@classmethod def df_from_groups_across_coordinate(cls, unit_groups, f, xs, name=None): def get_df(x): f(x) return cls.df_from_groups(unit_groups) dfs = [get_df(x) for x in xs] df0 = dfs[0] columns = list(df0) data = sum([[df[i] for df in dfs] for i in columns], []) df = pd.DataFrame(np.array(data).transpose(), index=df0.index, columns=pd.MultiIndex.from_product([columns, xs], names=['Metric', name]), ) return df def show(self): units = self.units if units: units = '\n' + repr_items(' units: ', units) else: units = "\n units: (No units)" metrics = self.metrics if metrics: metric_newline = "\n" + " " * len(' metrics: ') metrics = f"\n metrics: {metric_newline.join([i.describe() for i in self.metrics])}" else: metrics = "" print ( f"{type(self).__name__}: {self.name}" + units + metrics ) _ipython_display_ = show def __repr__(self): return f"{type(self).__name__}({repr(self.name)}, {self.units}, metrics={self.metrics})"
# %% UnitGroup as an actual unit operation def create_connected_grouped_units(unit_groups): grouped_units = [GroupedUnit('.' + i.name, units=i) for i in unit_groups] utils.connect_by_ID(grouped_units) return grouped_units class GroupedUnit(Unit): line = '' _ins_size_is_fixed = _outs_size_is_fixed = False _N_ins = _N_outs = 0 def __init__(self, ID=None, thermo=None, *, units): ins = [] outs = [] self.units = set(units) for s in streams_from_units(units): source = s._source sink = s._sink if source in units and sink not in units: outs.append(s.copy('.' + s.ID)) elif sink in units and source not in units: ins.append(s.copy('.' + s.ID)) super().__init__(ID, ins, outs, thermo) def _assert_compatible_property_package(self): pass @property def auxiliary_unit_names(self): return [i.ID for i in self.units] @property def auxiliary_units(self): return self.units def _run(self): pass