diff --git a/.github/PULL_REQUEST_TEMPLATE/release.md b/.github/PULL_REQUEST_TEMPLATE/release.md new file mode 100644 index 00000000..0b73c904 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/release.md @@ -0,0 +1,16 @@ +## Workflow to release a new version `vX.Y.Z` + +- [ ] Create new branch `vX.Y.Z` from `dev` +- [ ] Bump version in `setup.cfg` and `CADETProcess/__init__.py` +- [ ] Add release notes + - [ ] General description + - [ ] Deprecations / other changes + - [ ] Closed Issues/PRs + - [ ] Add entry in `index.md` +- [ ] Commit with message `vX.Y.Z` +- [ ] Add tag (`git tag 'vX.Y.Z'`) +- [ ] Push and open PR (base onto `master`). Also push tag: `git push origin --tag` +- [ ] When ready, rebase again onto `dev` (in case changes were made) +- [ ] Merge into master +- [ ] Make release on GitHub using tag and release notes. +- [ ] Check that workflows automatically publish to PyPI and readthedocs diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 6f23fe8d..4cc51ecd 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -21,9 +21,9 @@ jobs: os: [ubuntu-latest] python-version: ["3.10", "3.11", "3.12"] include: - - os: windows-latest - python-version: "3.12" - - os: macos-12 + # - os: windows-latest + # python-version: "3.12" + - os: macos-13 python-version: "3.12" env: @@ -40,7 +40,6 @@ jobs: - name: Setup Conda Environment uses: conda-incubator/setup-miniconda@v3 with: - miniforge-variant: Mambaforge miniforge-version: latest use-mamba: true activate-environment: cadet-process diff --git a/.zenodo.json b/.zenodo.json new file mode 100644 index 00000000..1dd9cc51 --- /dev/null +++ b/.zenodo.json @@ -0,0 +1,46 @@ +{ + "title": "CADET-Process v0.10.0", + "upload_type": "software", + "creators": [ + { + "name": "Schmölder, Johannes", + "orcid": "0000-0003-0446-7209", + "affiliation": "Forschungszentrum Jülich" + }, + { + "name": "Jäpel, Ronald", + "orcid": "0000-0002-4912-5176", + "affiliation": "Forschungszentrum Jülich" + }, + { + "name": "Schunck, Florian", + "orcid": "0000-0001-7245-953X", + "affiliation": "Forschungszentrum Jülich" + }, + { + "name": "Klauß, Daniel", + "orcid": "0009-0005-2022-7776", + "affiliation": "Forschungszentrum Jülich" + }, + { + "name": "Lanzrath, Hannah", + "orcid": "0000-0002-2675-9002", + "affiliation": "Forschungszentrum Jülich" + } + ], + "license": "GPL-3.0", + "keywords": [ + "modeling", + "simulation", + "biotechnology", + "process", + "chromatography", + "CADET", + "general rate model", + "Python" + ], + "version": "0.10.0", + "access_right": "open", + "communities": [{"identifier": "open-source"}], + "doi": "10.5281/zenodo.14202878" +} diff --git a/CADETProcess/__init__.py b/CADETProcess/__init__.py index 9b55fb6d..b1d55964 100644 --- a/CADETProcess/__init__.py +++ b/CADETProcess/__init__.py @@ -10,7 +10,7 @@ """ # Version information name = "CADET-Process" -__version__ = "0.9.1" +__version__ = "0.10.0" # Imports from .CADETProcessError import * diff --git a/CADETProcess/comparison/comparator.py b/CADETProcess/comparison/comparator.py index 15343efc..79cfcbe0 100644 --- a/CADETProcess/comparison/comparator.py +++ b/CADETProcess/comparison/comparator.py @@ -84,7 +84,7 @@ def metrics(self): return self._metrics @property - def n_diffference_metrics(self): + def n_difference_metrics(self): """int: Number of difference metrics in the Comparator.""" return len(self.metrics) @@ -242,11 +242,11 @@ def setup_comparison_figure( tuple A tuple of the comparison figure(s) and axes object(s). """ - if self.n_diffference_metrics == 0: + if self.n_difference_metrics == 0: return (None, None) comparison_fig_all, comparison_axs_all = plotting.setup_figure( - n_rows=self.n_diffference_metrics, + n_rows=self.n_difference_metrics, squeeze=False ) @@ -255,7 +255,7 @@ def setup_comparison_figure( comparison_fig_ind: list[Figure] = [] comparison_axs_ind: list[Axes] = [] - for i in range(self.n_diffference_metrics): + for i in range(self.n_difference_metrics): fig, axs = plt.subplots() comparison_fig_ind.append(fig) comparison_axs_ind.append(axs) @@ -271,24 +271,24 @@ def setup_comparison_figure( def plot_comparison( self, - simulation_results: list[SimulationResults], + simulation_results: SimulationResults, axs: Axes | list[Axes] | None = None, figs: Figure | list[Figure] | None = None, file_name: str | None = None, show: bool = True, plot_individual: bool = False, - use_minutes: bool = True, + x_axis_in_minutes: bool = True, ) -> tuple[list[Figure], list[Axes]]: """ Plot the comparison of the simulation results with the reference data. Parameters ---------- - simulation_results : list of SimulationResults - List of simulation results to compare to reference data. - axs : list of AxesSubplot, optional + simulation_results : SimulationResults + Simulation results to compare to reference data. + axs : list of Axes, optional List of subplot axes to use for plotting the metrics. - figs : list of Figure, optional + figs : list of Figures, optional List of figures to use for plotting the metrics. file_name : str, optional Name of the file to save the figure to. @@ -296,8 +296,8 @@ def plot_comparison( If True, displays the figure(s) on the screen. plot_individual : bool, optional If True, generates a separate figure for each metric. - use_minutes : bool, optional - Option to use x-aches (time) in minutes, default is set to True. + x_axis_in_minutes: bool, optional + If True, the x-axis will be plotted using minutes. The default is True. Returns ------- @@ -331,7 +331,7 @@ def plot_comparison( 'label': 'reference', } ref_time = metric.reference.time - if use_minutes: + if x_axis_in_minutes: ref_time = ref_time / 60 plotting.add_overlay(ax, metric.reference.solution, ref_time, **plot_args) diff --git a/CADETProcess/comparison/difference.py b/CADETProcess/comparison/difference.py index ff8c6c46..f19feea0 100644 --- a/CADETProcess/comparison/difference.py +++ b/CADETProcess/comparison/difference.py @@ -9,8 +9,9 @@ from CADETProcess import CADETProcessError from CADETProcess.dataStructure import UnsignedInteger -from CADETProcess.solution import SolutionBase, slice_solution +from CADETProcess.solution import SolutionIO, slice_solution from CADETProcess.metric import MetricBase +from CADETProcess.reference import ReferenceIO, FractionationReference from .shape import pearson, pearson_offset from .peaks import find_peaks, find_breakthroughs @@ -24,6 +25,7 @@ 'Shape', 'PeakHeight', 'PeakPosition', 'BreakthroughHeight', 'BreakthroughPosition', + 'FractionationSSE', ] @@ -74,7 +76,7 @@ class DifferenceBase(MetricBase): Parameters ---------- - reference : ReferenceIO + reference : ReferenceBase Reference used for calculating difference metric. components : {str, list}, optional Solution components to be considered. @@ -97,6 +99,8 @@ class DifferenceBase(MetricBase): If True, normalize data. The default is False. """ + _valid_references = () + def __init__( self, reference, @@ -106,6 +110,7 @@ def __init__( start=None, end=None, transform=None, + only_transforms_array=True, resample=True, smooth=False, normalize=False): @@ -113,7 +118,7 @@ def __init__( Parameters ---------- - reference : ReferenceIO + reference : ReferenceBase Reference used for calculating difference metric. components : {str, list}, optional Solution components to be considered. @@ -128,6 +133,8 @@ def __init__( End time of solution slice to be considerd. The default is None. transform : callable, optional Function to transform solution. The default is None. + only_transforms_array: bool, optional + If True, only transform np array of solution object. The default is True. resample : bool, optional If True, resample data. The default is True. smooth : bool, optional @@ -143,6 +150,7 @@ def __init__( self.start = start self.end = end self.transform = transform + self.only_transforms_array = only_transforms_array self.resample = resample self.smooth = smooth self.normalize = normalize @@ -165,8 +173,11 @@ def reference(self): @reference.setter def reference(self, reference): - if not isinstance(reference, SolutionBase): - raise TypeError("Expected SolutionBase") + if not isinstance(reference, self._valid_references): + raise TypeError( + f"Invalid reference type: {type(reference)}. " + f"Expected types: {self._valid_references}." + ) self._reference = copy.deepcopy(reference) if self.resample and not self._reference.is_resampled: @@ -221,11 +232,12 @@ def resamples_smoothes_and_normalizes_solution(func): @wraps(func) def wrapper(self, solution, *args, **kwargs): solution = copy.deepcopy(solution) - solution.resample( - self._reference.time[0], - self._reference.time[-1], - len(self._reference.time), - ) + if self.resample: + solution.resample( + self._reference.time[0], + self._reference.time[-1], + len(self._reference.time), + ) if self.normalize and not solution.is_normalized: solution.normalize() if self.smooth and not solution.is_smoothed: @@ -241,7 +253,11 @@ def transforms_solution(func): def wrapper(self, solution, *args, **kwargs): if self.transform is not None: solution = copy.deepcopy(solution) - solution.solution = self.transform(solution.solution) + + if self.only_transforms_array: + solution.solution = self.transform(solution.solution) + else: + solution = self.transform(solution) value = func(self, solution, *args, **kwargs) return value @@ -321,6 +337,8 @@ def calculate_sse(simulation, reference): class SSE(DifferenceBase): """Sum of squared errors (SSE) difference metric.""" + _valid_references = (ReferenceIO, SolutionIO) + def _evaluate(self, solution): sse = calculate_sse(solution.solution, self.reference.solution) @@ -348,6 +366,8 @@ def calculate_rmse(simulation, reference): class RMSE(DifferenceBase): """Root mean squared errors (RMSE) difference metric.""" + _valid_references = (SolutionIO, ReferenceIO) + def _evaluate(self, solution): rmse = calculate_rmse(solution.solution, self.reference.solution) @@ -357,6 +377,8 @@ def _evaluate(self, solution): class NRMSE(DifferenceBase): """Normalized root mean squared errors (RRMSE) difference metric.""" + _valid_references = (SolutionIO, ReferenceIO) + def _evaluate(self, solution): rmse = calculate_rmse(solution.solution, self.reference.solution) nrmse = rmse / np.max(self.reference.solution, axis=0) @@ -373,6 +395,8 @@ class Norm(DifferenceBase): The order of the norm. """ + _valid_references = (SolutionIO, ReferenceIO) + order = UnsignedInteger() def _evaluate(self, solution): @@ -398,6 +422,8 @@ class L2(Norm): class AbsoluteArea(DifferenceBase): """Absolute difference in area difference metric.""" + _valid_references = (SolutionIO, ReferenceIO) + def _evaluate(self, solution): """np.array: Absolute difference in area compared to reference. @@ -418,6 +444,8 @@ def _evaluate(self, solution): class RelativeArea(DifferenceBase): """Relative difference in area difference metric.""" + _valid_references = (SolutionIO, ReferenceIO) + def _evaluate(self, solution): """np.array: Relative difference in area compared to reference. @@ -462,6 +490,8 @@ class Shape(DifferenceBase): """ + _valid_references = (SolutionIO, ReferenceIO) + @wraps(DifferenceBase.__init__) def __init__( self, *args, @@ -645,6 +675,8 @@ class PeakHeight(DifferenceBase): Contains the normalization factors for each peak in each component. """ + _valid_references = (SolutionIO, ReferenceIO) + @wraps(DifferenceBase.__init__) def __init__( self, *args, @@ -737,6 +769,8 @@ class PeakPosition(DifferenceBase): Contains the normalization factors for each peak in each component. """ + _valid_references = (SolutionIO, ReferenceIO) + @wraps(DifferenceBase.__init__) def __init__(self, *args, normalize_metrics=True, normalization_factor=None, **kwargs): """Initialize PeakPosition object. @@ -823,6 +857,8 @@ class BreakthroughHeight(DifferenceBase): """ + _valid_references = (SolutionIO, ReferenceIO) + @wraps(DifferenceBase.__init__) def __init__(self, *args, normalize_metrics=True, **kwargs): """Initialize BreakthroughHeight metric. @@ -874,6 +910,8 @@ def _evaluate(self, solution): class BreakthroughPosition(DifferenceBase): """Absolute difference in breakthrough curve position difference metric.""" + _valid_references = (SolutionIO, ReferenceIO) + @wraps(DifferenceBase.__init__) def __init__(self, *args, normalize_metrics=True, normalization_factor=None, **kwargs): """ @@ -935,3 +973,61 @@ def _evaluate(self, solution): ] return np.abs(score) + + +class FractionationSSE(DifferenceBase): + """Fractionation based score using SSE.""" + + _valid_references = (FractionationReference) + + @wraps(DifferenceBase.__init__) + def __init__(self, *args, normalize_metrics=True, normalization_factor=None, **kwargs): + """ + Initialize the FractionationSSE object. + + Parameters + ---------- + *args : + Positional arguments for DifferenceBase. + normalize_metrics : bool, optional + Whether to normalize the metrics. Default is True. + normalization_factor : float, optional + Factor to use for normalization. + If None, it is set to the maximum of the difference between the reference + breakthrough and the start time, and the difference between the end time and + the reference breakthrough. + **kwargs : dict + Keyword arguments passed to the base class constructor. + + """ + super().__init__(*args, resample=False, only_transforms_array=False, **kwargs) + + if not isinstance(self.reference, FractionationReference): + raise TypeError("FractionationSSE can only work with FractionationReference") + + def transform(solution): + solution = copy.deepcopy(solution) + solution_fractions = [ + solution.create_fraction(frac.start, frac.end) + for frac in self.reference.fractions + ] + + solution.time = np.array([(frac.start + frac.end)/2 for frac in solution_fractions]) + solution.solution = np.array([frac.concentration for frac in solution_fractions]) + + return solution + + self.transform = transform + + def _evaluate(self, solution): + """np.array: Difference in breakthrough position (time). + + Parameters + ---------- + solution : SolutionIO + Concentration profile of simulation. + + """ + sse = calculate_sse(solution.solution, self.reference.solution) + + return sse diff --git a/CADETProcess/comparison/shape.py b/CADETProcess/comparison/shape.py index 12b19a2c..e86d63bb 100644 --- a/CADETProcess/comparison/shape.py +++ b/CADETProcess/comparison/shape.py @@ -76,7 +76,7 @@ def pear_corr(cr): @numba.njit(fastmath=True) def pearsonr_mat(x, Y, times): """High performance implementation of the pearson correlation. - This is to simulatneously evaluate the pearson correleation between a + This is to simultaneously evaluate the pearson correlation between a vector and a matrix. Scipy can only evaluate vector/vector. """ r = np.zeros(Y.shape[0]) @@ -90,14 +90,16 @@ def pearsonr_mat(x, Y, times): r_num = np.dot(xm, ym) r_y_den = np.linalg.norm(ym) - if r_y_den == 0.0: + denominator = r_x_den * r_y_den + + if denominator == 0.0: r[i] = -1.0 else: min_fun = 0 for j in range(x.shape[0]): min_fun += min(x[j], Y[i, j]) - r[i] = min(max(r_num/(r_x_den*r_y_den), -1.0), 1.0) * min_fun + r[i] = min(max(r_num/denominator, -1.0), 1.0) * min_fun return r diff --git a/CADETProcess/dataStructure/cache.py b/CADETProcess/dataStructure/cache.py index b1ff5330..b2c5f6a6 100644 --- a/CADETProcess/dataStructure/cache.py +++ b/CADETProcess/dataStructure/cache.py @@ -23,6 +23,19 @@ def name(self): class CachedPropertiesMixin(Structure): + """ + Mixin class for caching properties in a structured object. + + This class is designed to be used as a mixin in conjunction with other classes + inheriting from `Structure`. It provides functionality for caching properties and + managing a lock state to control the caching behavior. + + Notes + ----- + - To prevent the return of outdated state, the cache is cleared whenever the `lock` + state is changed. + """ + _lock = Bool(default=False) def __init__(self, *args, **kwargs): @@ -31,6 +44,7 @@ def __init__(self, *args, **kwargs): @property def lock(self): + """bool: If True, properties are cached. False otherwise.""" return self._lock @lock.setter diff --git a/CADETProcess/dataStructure/dataStructure.py b/CADETProcess/dataStructure/dataStructure.py index 1a74f87d..f18604a1 100644 --- a/CADETProcess/dataStructure/dataStructure.py +++ b/CADETProcess/dataStructure/dataStructure.py @@ -1,4 +1,4 @@ -from abc import ABC +from abc import ABC, ABCMeta from collections import OrderedDict from inspect import Parameter, Signature from functools import wraps @@ -325,8 +325,13 @@ def __new__(cls, clsname, bases, clsdict): return clsobj + +class AbstractStructMeta(StructMeta, ABCMeta): + pass + + # %% Stucture / ParameterHandler -class Structure(metaclass=StructMeta): +class Structure(metaclass=AbstractStructMeta): """ A class representing a structured data entity. @@ -359,18 +364,17 @@ def __init__(self, *args, **kwargs): **kwargs Keyword arguments representing parameters. """ + bound = self.__signature__.bind_partial(*args, **kwargs) + for name, val in bound.arguments.items(): + setattr(self, name, val) + self._parameters_dict = Dict() for param in self._parameters: value = getattr(self, param) if param in self._optional_parameters and value is None: continue - self._parameters_dict[param] = value - bound = self.__signature__.bind_partial(*args, **kwargs) - for name, val in bound.arguments.items(): - setattr(self, name, val) - @property def parameters(self): """dict: Parameters of the instance.""" diff --git a/CADETProcess/dataStructure/parameter.py b/CADETProcess/dataStructure/parameter.py index f9faebba..a15d0f3a 100644 --- a/CADETProcess/dataStructure/parameter.py +++ b/CADETProcess/dataStructure/parameter.py @@ -508,7 +508,7 @@ def cast_value(self, value): Union[bool, Any] Boolean equivalent if value is 0 or 1; otherwise, the original value. """ - if isinstance(value, int) and value in [0, 1]: + if isinstance(value, (int, np.bool_)) and value in [0, 1]: value = bool(value) return value diff --git a/CADETProcess/dynamicEvents/event.py b/CADETProcess/dynamicEvents/event.py index 8316ce23..af92df44 100644 --- a/CADETProcess/dynamicEvents/event.py +++ b/CADETProcess/dynamicEvents/event.py @@ -6,6 +6,7 @@ import numpy as np from matplotlib.axes import Axes + from CADETProcess import CADETProcessError from CADETProcess.dataStructure import Structure, frozen_attributes @@ -772,7 +773,7 @@ def check_uninitialized_indices(self): return flag - def plot_events(self, use_minutes: bool = True) -> list[Axes]: + def plot_events(self, x_axis_in_minutes: bool = True) -> list[Axes]: """ Plot parameter state as a function of time. @@ -782,38 +783,40 @@ def plot_events(self, use_minutes: bool = True) -> list[Axes]: Parameters ---------- - use_minutes: bool, optional - Option to use x-aches (time) in minutes, default is set to True. + x_axis_in_minutes: bool, optional + If True, the x-axis will be plotted using minutes. The default is True. Returns ------- - list of matplotlib.Axes - List of axes objects, each containing a plot of the parameter state. + list[Axes] + List of Axes objects, each containing a plot of the parameter state. Notes ----- The time is divided into 1001 linearly spaced points between 0 and the cycle time for the evaluation of the parameter state. """ - time = np.linspace(0, self.cycle_time, 1001) + time_s = np.linspace(0, self.cycle_time, 1001) + + time_ax = time_s + if x_axis_in_minutes: + time_ax = time_ax / 60 - if use_minutes: - time = time / 60 axs: list[Axes] = [] for parameter, tl in self.parameter_timelines.items(): fig, ax = plotting.setup_figure() - y = tl.value(time) + y = tl.value(time_s) layout = plotting.Layout() layout.title = str(parameter) layout.x_label = "$time~/~s$" - if use_minutes: + if x_axis_in_minutes: layout.x_label = "$time~/~min$" layout.y_label = '$state$' - ax.plot(time, y) + ax.plot(time_ax, y) plotting.set_layout(ax, layout) diff --git a/CADETProcess/dynamicEvents/section.py b/CADETProcess/dynamicEvents/section.py index 6e4a0624..0fd72247 100644 --- a/CADETProcess/dynamicEvents/section.py +++ b/CADETProcess/dynamicEvents/section.py @@ -2,11 +2,10 @@ import warnings import numpy as np -from numpy import VisibleDeprecationWarning +from numpy.exceptions import VisibleDeprecationWarning import scipy from matplotlib.axes import Axes - from CADETProcess import CADETProcessError from CADETProcess.dataStructure import Structure from CADETProcess.dataStructure import NdPolynomial @@ -128,6 +127,8 @@ def value(self, t): return value + __call__ = value + def coefficients(self, offset=0): """Get coefficients at (time) offset. @@ -407,15 +408,15 @@ def end(self): return self.section_times[-1] @plotting.create_and_save_figure - def plot(self, ax, use_minutes: bool = True) -> Axes: + def plot(self, ax, x_axis_in_minutes: bool = True) -> Axes: """Plot the state of the timeline over time. Parameters ---------- ax : Axes The axes to plot on. - use_minutes : bool, optional - Option to use x-aches (time) in minutes, default is set to True. + x_axis_in_minutes: bool, optional + If True, the x-axis will be plotted using minutes. The default is True. Returns ------- @@ -427,7 +428,7 @@ def plot(self, ax, use_minutes: bool = True) -> Axes: time = np.linspace(start, end, 1001) y = self.value(time) - if use_minutes: + if x_axis_in_minutes: time = time / 60 start = start / 60 end = end / 60 @@ -435,7 +436,9 @@ def plot(self, ax, use_minutes: bool = True) -> Axes: ax.plot(time, y) layout = plotting.Layout() - layout.x_label = '$time~/~min$' + layout.x_label = '$time~/~s$' + if x_axis_in_minutes: + layout.x_label = '$time~/~min$' layout.y_label = '$state$' layout.x_lim = (start, end) layout.y_lim = (np.min(y), 1.1*np.max(y)) diff --git a/CADETProcess/equilibria/initial_conditions.py b/CADETProcess/equilibria/initial_conditions.py index 160ae63d..96b92a85 100644 --- a/CADETProcess/equilibria/initial_conditions.py +++ b/CADETProcess/equilibria/initial_conditions.py @@ -52,8 +52,8 @@ def simulate_solid_equilibria( if unit_model == 'cstr': unit = Cstr(component_system, 'cstr') - unit.porosity = 0.5 - unit.V = 1e-6 + unit.init_liquid_volume = 5e-7 + unit.const_solid_volume = 5e-7 Q = 1e-6 cycle_time = np.round(1000*unit.volume/Q) diff --git a/CADETProcess/fractionation/fractionationOptimizer.py b/CADETProcess/fractionation/fractionationOptimizer.py index b2f1059c..c4510b75 100644 --- a/CADETProcess/fractionation/fractionationOptimizer.py +++ b/CADETProcess/fractionation/fractionationOptimizer.py @@ -368,12 +368,12 @@ def optimize_fractionation( opt, x0 = self._setup_optimization_problem( frac, purity_required, - allow_empty_fractions, - ranking, - obj_fun, - n_objectives, - bad_metrics, - minimize, + allow_empty_fractions=allow_empty_fractions, + ranking=ranking, + obj_fun=obj_fun, + n_objectives=n_objectives, + minimize=minimize, + bad_metrics=bad_metrics, ) # Lock to enable caching diff --git a/CADETProcess/fractionation/fractionator.py b/CADETProcess/fractionation/fractionator.py index 0f1c00fd..c3e32701 100644 --- a/CADETProcess/fractionation/fractionator.py +++ b/CADETProcess/fractionation/fractionator.py @@ -18,14 +18,22 @@ from CADETProcess.fractionation.fractions import Fraction, FractionPool +__all__ = ["Fractionator"] + + class Fractionator(EventHandler): """Class for Chromatogram Fractionation. - To set Events for starting and ending a fractionation it inherits from the - EventHandler class. It defines a ranking list for components as a - DependentlySizedUnsignedList with the number of components as dependent - size. The time_signal to fractionate is defined. If no ranking is set, - every component is equivalently. + This class is responsible for setting events for starting and ending fractionation, + handling multiple chromatograms, and calculating various performance metrics. + + Attributes + ---------- + name : String + Name of the fractionator, defaulting to 'Fractionator'. + performance_keys : list + Keys for performance metrics including mass, concentration, purity, recovery, + productivity, and eluent consumption. """ @@ -136,7 +144,7 @@ def component_system(self): """ComponentSystem: The component system of the chromatograms.""" return self.chromatograms[0].component_system - def call_by_chrom_name(func): + def _call_by_chrom_name(func): """Decorator to enable calling functions with chromatogram object or name.""" @wraps(func) def wrapper(self, chrom, *args, **kwargs): @@ -222,8 +230,8 @@ def time(self): def plot_fraction_signal( self, chromatogram: SolutionIO | None = None, - use_minutes: bool = True, - ax: Axes = None, + x_axis_in_minutes: bool = True, + ax: Axes | None = None, *args, **kwargs, ) -> Axes: @@ -233,9 +241,9 @@ def plot_fraction_signal( ---------- chromatogram : SolutionIO, optional Chromatogram to be plotted. If None, the first one is plotted. - ax : Axes - Axes to plot on. - use_minutes: bool, optional + ax : Axes, optional + Axes to plot on. If None, a new figure is created. + x_axis_in_minutes: bool, optional Option to use x-aches (time) in minutes, default is set to True. Returns @@ -260,18 +268,20 @@ def plot_fraction_signal( try: start = kwargs['start'] - if use_minutes: + if x_axis_in_minutes: start = start / 60 except KeyError: start = 0 try: end = kwargs['end'] - if use_minutes: + if x_axis_in_minutes: end = end / 60 except KeyError: end = np.max(chromatogram.time) - _, ax = chromatogram.plot(show=False, ax=ax, *args, **kwargs) + _, ax = chromatogram.plot( + show=False, ax=ax, x_axis_in_minutes=x_axis_in_minutes, *args, **kwargs + ) y_max = 1.1*np.max(chromatogram.solution) @@ -288,7 +298,7 @@ def plot_fraction_signal( sec_start = sec.start sec_end = sec.end - if use_minutes: + if x_axis_in_minutes: sec_start = sec_start / 60 sec_end = sec_end / 60 @@ -329,7 +339,7 @@ def fractionation_states(self): """ return self._fractionation_states - @call_by_chrom_name + @_call_by_chrom_name def set_fractionation_state(self, chrom, state): """Set fractionation states of Chromatogram. @@ -436,7 +446,7 @@ def _create_fraction(self, chrom_index, start, end): start : float start time of the fraction start : float - start time of the fraction + end time of the fraction Returns ------- @@ -444,9 +454,9 @@ def _create_fraction(self, chrom_index, start, end): Chromatogram fraction """ - mass = self.chromatograms[chrom_index].fraction_mass(start, end) - volume = self.chromatograms[chrom_index].fraction_volume(start, end) - return Fraction(mass, volume) + fraction = self.chromatograms[chrom_index].create_fraction(start, end) + + return fraction def add_fraction(self, fraction, target): """Add Fraction to the FractionPool of target component. diff --git a/CADETProcess/fractionation/fractions.py b/CADETProcess/fractionation/fractions.py index 8475106a..a8adffc1 100644 --- a/CADETProcess/fractionation/fractions.py +++ b/CADETProcess/fractionation/fractions.py @@ -7,6 +7,9 @@ ) +__all__ = ["Fraction", "FractionPool"] + + class Fraction(Structure): """A class representing a fraction of a mixture. @@ -16,27 +19,33 @@ class Fraction(Structure): Attributes ---------- mass : np.ndarray - The mass of each component in the fraction. The array is 1-dimensional. + Mass of each component in the fraction. volume : float - The volume of the fraction. + Volume of the fraction. + + Properties + ---------- + n_comp : int + Number of components in the fraction. + fraction_mass : np.ndarray + Cumulative mass of all species in the fraction. + purity : np.ndarray + Purity of the fraction, with invalid values set to zero. + concentration : np.ndarray + Component concentrations of the fraction, with invalid values set to zero. + + See Also + -------- + CADETProcess.fractionation.FractionPool + CADETProcess.fractionation.Fractionator """ mass = Vector() volume = UnsignedFloat() + start = UnsignedFloat() + end = UnsignedFloat() - def __init__(self, mass, volume): - """Initialize a Fraction instance. - - Parameters - ---------- - mass : numpy.ndarray - The mass of each component in the fraction. - The array should be 1-dimensional. - volume : float - The volume of the fraction. - """ - self.mass = mass - self.volume = volume + _parameters = ['mass', 'volume', 'start', 'end'] @property def n_comp(self): @@ -106,6 +115,23 @@ class FractionPool(Structure): n_comp : int The number of components each fraction in the pool should have. + Properties + ---------- + fractions : list + List of fractions in the pool. + n_fractions : int + Number of fractions in the pool. + volume : float + Total volume of all fractions in the pool. + mass : np.ndarray + Cumulative mass of each component in the pool. + pool_mass : float + Total mass of all components in the pool. + purity : np.ndarray + Overall purity of the pool, with invalid values set to zero. + concentration : np.ndarray + Average concentration of the pool, with invalid values set to zero. + See Also -------- CADETProcess.fractionation.Fraction @@ -114,7 +140,9 @@ class FractionPool(Structure): n_comp = UnsignedInteger() - def __init__(self, n_comp): + _parameters = ['n_comp'] + + def __init__(self, n_comp, *args, **kwargs): """Initialize a FractionPool instance. Parameters @@ -125,6 +153,8 @@ def __init__(self, n_comp): self._fractions = [] self.n_comp = n_comp + super().__init__(*args, **kwargs) + def add_fraction(self, fraction): """Add a fraction to the fraction pool. diff --git a/CADETProcess/modelBuilder/carouselBuilder.py b/CADETProcess/modelBuilder/carouselBuilder.py index a4d677db..121bb45f 100644 --- a/CADETProcess/modelBuilder/carouselBuilder.py +++ b/CADETProcess/modelBuilder/carouselBuilder.py @@ -80,9 +80,9 @@ def __init__( self._inlet_unit = Cstr(component_system, f'{name}_inlet') - self._inlet_unit.V = self.valve_dead_volume + self._inlet_unit.init_liquid_volume = self.valve_dead_volume self._outlet_unit = Cstr(component_system, f'{name}_outlet') - self._outlet_unit.V = self.valve_dead_volume + self._outlet_unit.init_liquid_volume = self.valve_dead_volume super().__init__(component_system, name, *args, **kwargs) @@ -283,12 +283,12 @@ def _add_inter_zone_connections(self, flow_sheet: FlowSheet) -> NoReturn: origin = unit.outlet_unit else: origin = unit + if connections.destinations: + for destination in connections.destinations[None]: + if isinstance(destination, ZoneBaseClass): + destination = destination.inlet_unit - for destination in connections.destinations: - if isinstance(destination, ZoneBaseClass): - destination = destination.inlet_unit - - flow_sheet.add_connection(origin, destination) + flow_sheet.add_connection(origin, destination) for zone in self.zones: output_state = self.flow_sheet.output_states[zone] diff --git a/CADETProcess/modelBuilder/compartmentBuilder.py b/CADETProcess/modelBuilder/compartmentBuilder.py index 9a99dde8..98bcc3fc 100644 --- a/CADETProcess/modelBuilder/compartmentBuilder.py +++ b/CADETProcess/modelBuilder/compartmentBuilder.py @@ -157,7 +157,7 @@ def _add_compartments(self, compartment_volumes): unit = Outlet(self.component_system, name) else: unit = Cstr(self.component_system, name) - unit.V = vol + unit.init_liquid_volume = vol self.flow_sheet.add_unit(unit) @@ -290,14 +290,19 @@ def validate_flow_rates(self): flow_rates = self.flow_sheet.get_flow_rates() for comp in self._real_compartments: - if not np.all( - np.isclose( - flow_rates[comp.name].total_in, - flow_rates[comp.name].total_out - )): - raise CADETProcessError( - f"Unbalanced flow rate for compartment '{comp.name}'." - ) + for port in flow_rates[comp.name].total_in: + if not np.all( + np.isclose( + flow_rates[comp.name].total_in[port], + flow_rates[comp.name].total_out[port] + )): + if comp.n_ports == 1: + msg = comp.name + else: + msg = f"{comp.name} at Port {port}" + raise CADETProcessError( + f"Unbalanced flow rate for compartment '{msg}'." + ) class CompartmentModel(Cstr): diff --git a/CADETProcess/optimization/__init__.py b/CADETProcess/optimization/__init__.py index 615ea551..842e2832 100644 --- a/CADETProcess/optimization/__init__.py +++ b/CADETProcess/optimization/__init__.py @@ -107,14 +107,14 @@ import importlib try: - from .axAdapater import BotorchModular, GPEI, NEHVI + from .axAdapater import BotorchModular, GPEI, NEHVI, qNParEGO ax_imported = True except ImportError: ax_imported = False def __getattr__(name): - if name in ('BotorchModular', 'GPEI', 'NEHVI'): + if name in ("BotorchModular", "GPEI", "NEHVI", "qNParEGO"): if ax_imported: module = importlib.import_module("axAdapter", package=__name__) return getattr(module, name) diff --git a/CADETProcess/optimization/axAdapater.py b/CADETProcess/optimization/axAdapater.py index 4533be4e..3c9abb9e 100644 --- a/CADETProcess/optimization/axAdapater.py +++ b/CADETProcess/optimization/axAdapater.py @@ -15,15 +15,17 @@ from ax.global_stopping.strategies.improvement import ImprovementGlobalStoppingStrategy from ax.core.metric import MetricFetchResult, MetricFetchE from ax.core.base_trial import BaseTrial +from ax.models.torch.botorch_defaults import get_qLogNEI from ax.models.torch.botorch_modular.surrogate import Surrogate from ax.utils.common.result import Err, Ok from ax.service.utils.report_utils import exp_to_df from botorch.utils.sampling import manual_seed -from botorch.models.gp_regression import FixedNoiseGP +from botorch.models.gp_regression import SingleTaskGP from botorch.acquisition.analytic import ( LogExpectedImprovement ) +from CADETProcess import CADETProcessError from CADETProcess.dataStructure import UnsignedInteger, Typed, Float from CADETProcess.optimization.optimizationProblem import OptimizationProblem from CADETProcess.optimization import OptimizerBase @@ -32,9 +34,10 @@ ParallelizationBackendBase ) __all__ = [ - 'GPEI', - 'BotorchModular', - 'NEHVI', + "GPEI", + "BotorchModular", + "NEHVI", + "qNParEGO", ] @@ -54,7 +57,6 @@ def fetch_trial_data( trial_results = trial.run_metadata records = [] for arm_name, arm in trial.arms_by_name.items(): - results_dict = { "trial_index": trial.index, "arm_name": arm_name, @@ -104,11 +106,12 @@ def run(self, trial: BaseTrial) -> Dict[str, Any]: # Calculate objectives # Explore if adding a small amount of noise to the result helps BO objective_labels = self.optimization_problem.objective_labels - obj_fun = self.optimization_problem.evaluate_objectives_population + obj_fun = self.optimization_problem.evaluate_objectives F = obj_fun( X, untransform=True, + get_dependent_values=True, ensure_minimization=True, parallelization_backend=self.parallelization_backend ) @@ -116,12 +119,13 @@ def run(self, trial: BaseTrial) -> Dict[str, Any]: # Calculate nonlinear constraints # Explore if adding a small amount of noise to the result helps BO if self.optimization_problem.n_nonlinear_constraints > 0: - nonlincon_cv_fun = self.optimization_problem.evaluate_nonlinear_constraints_violation_population + nonlincon_cv_fun = self.optimization_problem.evaluate_nonlinear_constraints_violation nonlincon_labels = self.optimization_problem.nonlinear_constraint_labels CV = nonlincon_cv_fun( X, untransform=True, + get_dependent_values=True, parallelization_backend=self.parallelization_backend ) @@ -174,7 +178,7 @@ class AxInterface(OptimizerBase): _specific_options = [ 'n_init_evals', - 'n_max_evals,' + 'n_max_evals', 'seed', 'early_stopping_improvement_window', 'early_stopping_improvement_bar', @@ -312,8 +316,8 @@ def _post_processing(self, trial): assert np.all(G_data["metric_name"].values.tolist() == np.repeat(nonlincon_labels, len(X))) G = G_data["mean"].values.reshape((op.n_nonlinear_constraints, n_ind)).T - nonlincon_cv_fun = op.evaluate_nonlinear_constraints_violation_population - CV = nonlincon_cv_fun(X, untransform=True) + nonlincon_cv_fun = op.evaluate_nonlinear_constraints_violation + CV = nonlincon_cv_fun(X, untransform=True, get_dependent_values=True) else: G = None CV = None @@ -327,7 +331,7 @@ def _post_processing(self, trial): X_transformed=X, F_minimized=F, G=G, - CV=CV, + CV_nonlincon=CV, current_generation=self.ax_experiment.num_trials, X_opt_transformed=None, ) @@ -422,6 +426,14 @@ def run(self, optimization_problem, x0): n_iter = self.results.n_gen n_evals = self.results.n_evals + global_stopping_message = None + + if n_evals >= self.n_max_evals: + raise CADETProcessError( + f"Initial number of evaluations exceeds `n_max_evals` " + f"({self.n_max_evals})." + ) + with manual_seed(seed=self.seed): while not (n_evals >= self.n_max_evals or n_iter >= self.n_max_iter): # Reinitialize GP+EI model at each step with updated data. @@ -509,7 +521,7 @@ class BotorchModular(SingleObjectiveAxInterface): surrogate_model: Model class """ acquisition_fn = Typed(ty=type, default=LogExpectedImprovement) - surrogate_model = Typed(ty=type, default=FixedNoiseGP) + surrogate_model = Typed(ty=type, default=SingleTaskGP) _specific_options = [ 'acquisition_fn', 'surrogate_model' @@ -540,7 +552,7 @@ class NEHVI(MultiObjectiveAxInterface): supports_single_objective = False def __repr__(self): - smn = 'FixedNoiseGP' + smn = 'SingleTaskGP' afn = 'NEHVI' return f'{smn}+{afn}' @@ -550,3 +562,35 @@ def train_model(self): experiment=self.ax_experiment, data=self.ax_experiment.fetch_data() ) + + +class qNParEGO(MultiObjectiveAxInterface): + """ + Multi objective Bayesian optimization algorithm with the qNParEGO acquisition function. + ParEGO transforms the MOO problem into a single objective problem by applying a randomly weighted augmented + Chebyshev scalarization to the objectives, and maximizing the expected improvement of that scalarized + quantity (Knowles, 2006). Recently, Daulton et al. (2020) used a multi-output Gaussian process and compositional + Monte Carlo objective to extend ParEGO to the batch setting (qParEGO), which proved to be a strong baseline for + MOBO. Additionally, the authors proposed a noisy variant (qNParEGO), but the empirical evaluation of qNParEGO + was limited. [Daulton et al. 2021 "Parallel Bayesian Optimization of Multiple Noisy Objectives with Expected + Hypervolume Improvement"] + """ + supports_single_objective = False + + def __repr__(self): + smn = 'SingleTaskGP' + afn = 'qNParEGO' + + return f'{smn}+{afn}' + + def train_model(self): + return Models.MOO( + experiment=self.ax_experiment, + data=self.ax_experiment.fetch_data(), + acqf_constructor=get_qLogNEI, + default_model_gen_options={ + "acquisition_function_kwargs": { + "chebyshev_scalarization": True, + } + }, + ) diff --git a/CADETProcess/optimization/cache.py b/CADETProcess/optimization/cache.py index aa5e6b08..96991838 100644 --- a/CADETProcess/optimization/cache.py +++ b/CADETProcess/optimization/cache.py @@ -3,7 +3,7 @@ import shutil import tempfile -from diskcache import Cache +from diskcache import Cache, FanoutCache from CADETProcess import CADETProcessError from CADETProcess.dataStructure import DillDisk @@ -39,9 +39,10 @@ class ResultsCache(): CADETProcess.optimization.OptimizationProblem.add_evaluator """ - def __init__(self, use_diskcache=False, directory=None): + def __init__(self, use_diskcache=False, directory=None, n_shards=1): self.use_diskcache = use_diskcache self.directory = directory + self.n_shards = n_shards self.init_cache() self.tags = defaultdict(list) @@ -57,13 +58,23 @@ def init_cache(self): self.directory.mkdir(exist_ok=True, parents=True) - self.cache = Cache( - self.directory.as_posix(), - disk=DillDisk, - disk_min_file_size=2**18, # 256 kb - size_limit=2**36, # 64 GB - tag_index=True, - ) + if self.n_shards == 1: + self.cache = Cache( + self.directory.as_posix(), + disk=DillDisk, + disk_min_file_size=2**18, # 256 kb + size_limit=2**36, # 64 GB + tag_index=True, + ) + else: + self.cache = FanoutCache( + self.directory.as_posix(), + shards=self.n_shards, + disk=DillDisk, + disk_min_file_size=2**18, # 256 kb + size_limit=2**36, # 64 GB + tag_index=True, + ) self.directory = self.cache.directory else: self.cache = {} @@ -79,6 +90,8 @@ def set(self, key, value, tag=None, close=True): The value corresponding to the key. tag : str, optional Tag to associate with result. The default is None. + close : bool, optional + If True, database will be closed after operation. The default is True. """ if self.use_diskcache: self.cache.set(key, value, tag=tag, expire=None) @@ -91,12 +104,15 @@ def set(self, key, value, tag=None, close=True): self.close() def get(self, key, close=True): - """Get entry from cache. + """ + Get entry from cache. Parameters ---------- key : hashable The key to retrieve the results for. + close : bool, optional + If True, database will be closed after operation. The default is True. Returns ------- @@ -111,15 +127,15 @@ def get(self, key, close=True): return value def delete(self, key, close=True): - """Remove entry from cache. + """ + Remove entry from cache. Parameters ---------- key : hashable The key to retrieve the results for. - value : object - The value corresponding to the key. - + close : bool, optional + If True, database will be closed after operation. The default is True. """ if self.use_diskcache: found = self.cache.delete(key) @@ -131,13 +147,16 @@ def delete(self, key, close=True): if close: self.close() - def prune(self, tag): - """Remove tagged entries from cache. + def prune(self, tag, close=True): + """ + Remove tagged entries from cache. Parameters ---------- - tag : str, optional - Tag to be removed. The default is 'temp'. + tag : str + Tag to be removed. + close : bool, optional + If True, database will be closed after operation. The default is True. """ if self.use_diskcache: self.cache.evict(tag) @@ -150,7 +169,8 @@ def prune(self, tag): except KeyError: pass - self.close() + if close: + self.close() def close(self): """Close cache.""" diff --git a/CADETProcess/optimization/individual.py b/CADETProcess/optimization/individual.py index c3229ad8..01bee878 100644 --- a/CADETProcess/optimization/individual.py +++ b/CADETProcess/optimization/individual.py @@ -5,10 +5,10 @@ from CADETProcess import CADETProcessError from CADETProcess.dataStructure import Structure -from CADETProcess.dataStructure import Float, Vector +from CADETProcess.dataStructure import Bool, Float, Vector -def hash_array(array): +def hash_array(array: np.ndarray) -> str: """Compute a hash value for an array of floats using the sha256 hash function. Parameters @@ -37,24 +37,32 @@ class Individual(Structure): Attributes ---------- + id : str + UUID for individual. x : np.ndarray Variable values in untransformed space. x_transformed : np.ndarray Independent variable values in transformed space. + cv_bounds : np.ndarray + Vound constraint violations. + cv_lincon : np.ndarray + Linear constraint violations. + cv_lineqcon : np.ndarray + Linear equality constraint violations. f : np.ndarray Objective values. f_min : np.ndarray Minimized objective values. g : np.ndarray Nonlinear constraint values. - cv : np.ndarray + cv_nonlincon : np.ndarray Nonlinear constraints violation. - cv_tol : float - Tolerance for constraints violation. m : np.ndarray Meta score values. m_min : np.ndarray Minimized meta score values. + is_feasible : bool + True, if individual fulfills all constraints. See Also -------- @@ -63,46 +71,57 @@ class Individual(Structure): x = Vector() x_transformed = Vector() + cv_bounds = Vector() + cv_lincon = Vector() + cv_lineqcon = Vector() f = Vector() f_min = Vector() g = Vector() - cv = Vector() - cv_tol = Float() + cv_nonlincon = Vector() + cv_nonlincon_tol = Float() m = Vector() m_min = Vector() + is_feasible = Bool() def __init__( self, x, f=None, g=None, - m=None, - x_transformed=None, f_min=None, - cv=None, - cv_tol=0, + x_transformed=None, + cv_bounds=None, + cv_lincon=None, + cv_lineqcon=None, + cv_nonlincon=None, + m=None, m_min=None, independent_variable_names=None, objective_labels=None, - contraint_labels=None, + nonlinear_constraint_labels=None, meta_score_labels=None, - variable_names=None): + variable_names=None, + is_feasible=True, + ): self.x = x if x_transformed is None: x_transformed = x independent_variable_names = variable_names self.x_transformed = x_transformed + self.cv_bounds = cv_bounds + self.cv_lincon = cv_lincon + self.cv_lineqcon = cv_lineqcon + self.f = f if f_min is None: f_min = f self.f_min = f_min self.g = g - if g is not None and cv is None: - cv = g - self.cv = cv - self.cv_tol = cv_tol + if g is not None and cv_nonlincon is None: + cv_nonlincon = g + self.cv_nonlincon = cv_nonlincon self.m = m if m_min is None: @@ -118,21 +137,25 @@ def __init__( if isinstance(objective_labels, np.ndarray): objective_labels = [s.decode() for s in objective_labels] self.objective_labels = objective_labels - if isinstance(contraint_labels, np.ndarray): - contraint_labels = [s.decode() for s in contraint_labels] - self.contraint_labels = contraint_labels + if isinstance(nonlinear_constraint_labels, np.ndarray): + nonlinear_constraint_labels = [ + s.decode() for s in nonlinear_constraint_labels + ] + self.nonlinear_constraint_labels = nonlinear_constraint_labels if isinstance(meta_score_labels, np.ndarray): meta_score_labels = [s.decode() for s in meta_score_labels] self.meta_score_labels = meta_score_labels self.id = hash_array(self.x) + self.is_feasible = is_feasible @property - def id_short(self): + def id_short(self) -> str: + """str: Id shortened to the first seven digits.""" return self.id[0:7] @property - def is_evaluated(self): + def is_evaluated(self) -> bool: """bool: Return True if individual has been evaluated. False otherwise.""" if self.f is None: return False @@ -140,27 +163,19 @@ def is_evaluated(self): return True @property - def is_feasible(self): - """bool: Return False if any constraint is not met. True otherwise.""" - if self.cv is not None and np.any(np.array(self.cv) > self.cv_tol): - return False - else: - return True - - @property - def n_x(self): + def n_x(self) -> int: """int: Number of variables.""" return len(self.x) @property - def n_f(self): + def n_f(self) -> int: """int: Number of objectives.""" if self.f is None: return 0 return len(self.f) @property - def n_g(self): + def n_g(self) -> int: """int: Number of nonlinear constraints.""" if self.g is None: return 0 @@ -168,7 +183,7 @@ def n_g(self): return len(self.g) @property - def n_m(self): + def n_m(self) -> int: """int: Number of meta scores.""" if self.m is None: return 0 @@ -176,19 +191,37 @@ def n_m(self): return len(self.m) @property - def dimensions(self): - """tuple: Individual dimensions (n_x, n_f, n_g, n_m)""" + def cv(self) -> np.ndarray: + """ + All constraint violations combined. + + (cv_bounds, cv_lincon, cv_lineqcon, cv_nonlincon) + + Returns + ------- + np.ndarray + All constraint violations combined. + + """ + cvs = (self.cv_bounds, self.cv_lincon, self.cv_lineqcon, self.cv_nonlincon) + return np.concatenate([cv for cv in cvs if cv is not None]) + + @property + def dimensions(self) -> tuple[int]: + """tuple: Individual dimensions (n_x, n_f, n_g, n_m).""" return (self.n_x, self.n_f, self.n_g, self.n_m) @property - def objectives_minimization_factors(self): + def objectives_minimization_factors(self) -> np.ndarray: + """np.ndarray: Array indicating objectives transformed to minimization.""" return self.f_min / self.f @property - def meta_scores_minimization_factors(self): + def meta_scores_minimization_factors(self) -> np.ndarray: + """np.ndarray: Array indicating meta sorces transformed to minimization.""" return self.m_min / self.m - def dominates(self, other): + def dominates(self, other: "Individual") -> bool: """Determine if individual dominates other. Parameters @@ -207,12 +240,17 @@ def dominates(self, other): raise CADETProcessError("Individual needs to be evaluated first.") if not other.is_evaluated: raise CADETProcessError("Other individual needs to be evaluated first.") + if self.is_feasible and not other.is_feasible: return True + if not self.is_feasible and other.is_feasible: + return False if not self.is_feasible and not other.is_feasible: if np.any(self.cv < other.cv): - return True + better_in_all = np.all(self.cv <= other.cv) + strictly_better_in_one = np.any(self.cv < other.cv) + return better_in_all and strictly_better_in_one if self.m is not None: self_values = self.m @@ -229,7 +267,7 @@ def dominates(self, other): return False - def is_similar(self, other, tol=1e-1): + def is_similar(self, other: "Individual", tol: float = 1e-1) -> bool: """Determine if individual is similar to other. Parameters @@ -262,7 +300,12 @@ def is_similar(self, other, tol=1e-1): return similar_x and similar_f and similar_g and similar_m - def is_similar_x(self, other, tol=1e-1, use_transformed=False): + def is_similar_x( + self, + other: "Individual", + tol: float = 1e-1, + use_transformed: bool = False, + ) -> bool: """Determine if individual is similar to other based on parameter values. Parameters @@ -285,7 +328,7 @@ def is_similar_x(self, other, tol=1e-1, use_transformed=False): return similar_x - def is_similar_f(self, other, tol=1e-1): + def is_similar_f(self, other: "Individual", tol: float = 1e-1) -> bool: """Determine if individual is similar to other based on objective values. Parameters @@ -305,7 +348,7 @@ def is_similar_f(self, other, tol=1e-1): return similar_f - def is_similar_g(self, other, tol=1e-1): + def is_similar_g(self, other: "Individual", tol: float | None = 1e-1) -> bool: """Determine if individual is similar to other based on constraint values. Parameters @@ -325,7 +368,7 @@ def is_similar_g(self, other, tol=1e-1): return similar_g - def is_similar_m(self, other, tol=1e-1): + def is_similar_m(self, other: "Individual", tol: float | None = 1e-1) -> bool: """Determine if individual is similar to other based on meta score values. Parameters @@ -345,16 +388,16 @@ def is_similar_m(self, other, tol=1e-1): return similar_m - def __str__(self): + def __str__(self) -> str: return str(list(self.x)) - def __repr__(self): + def __repr__(self) -> str: if self.g is None: return f'{self.__class__.__name__}({self.x}, {self.f})' else: return f'{self.__class__.__name__}({self.x}, {self.f}, {self.g})' - def to_dict(self): + def to_dict(self) -> dict: """Convert individual to a dictionary. Returns @@ -364,27 +407,38 @@ def to_dict(self): data = Dict() data.x = self.x + data.x_transformed = self.x_transformed + + data.cv_bounds = self.cv_bounds + data.cv_lincon = self.cv_lincon + data.cv_lineqcon = self.cv_lineqcon + data.f = self.f + data.f_min = self.f_min + if self.g is not None: data.g = self.g - if self.cv is not None: - data.cv = self.cv + data.cv_nonlincon = self.cv_nonlincon + if self.m is not None: data.m = self.m - data.x_transformed = self.x_transformed + data.m_min = self.m_min + data.variable_names = self.variable_names data.independent_variable_names = self.independent_variable_names if self.objective_labels is not None: data.objective_labels = self.objective_labels - if self.contraint_labels is not None: - data.contraint_labels = self.contraint_labels + if self.nonlinear_constraint_labels is not None: + data.nonlinear_constraint_labels = self.nonlinear_constraint_labels if self.meta_score_labels is not None: data.meta_score_labels = self.meta_score_labels + data.is_feasible = self.is_feasible + return data @classmethod - def from_dict(cls, data): + def from_dict(cls, data: dict) -> "Individual": """Create Individual from dictionary representation of its attributes. Parameters @@ -397,5 +451,4 @@ def from_dict(cls, data): individual Individual idual created from the dictionary. """ - return cls(**data) diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index 7b77c4ce..760450cb 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -1,17 +1,18 @@ -from collections import defaultdict import copy -from functools import wraps +from functools import partial, wraps import inspect import math from pathlib import Path import random import shutil +from typing import Any, Optional, NoReturn import uuid import warnings from addict import Dict -import numpy as np import hopsy +import numpy as np +import numpy.typing as npt from CADETProcess import CADETProcessError from CADETProcess import log @@ -31,7 +32,9 @@ generate_indices, unravel, get_inhomogeneous_shape, get_full_shape ) -from CADETProcess.optimization.parallelizationBackend import SequentialBackend +from CADETProcess.optimization.parallelizationBackend import ( + ParallelizationBackendBase, SequentialBackend +) from CADETProcess.transform import ( NoTransform, AutoTransform, NormLinearTransform, NormLogTransform ) @@ -128,41 +131,56 @@ def __init__( def untransforms(func): """Untransform population or individual before calling function.""" @wraps(func) - def wrapper(self, x, *args, untransform=False, **kwargs): + def wrapper_untransforms(self, x, *args, untransform=False, **kwargs): x = np.array(x, ndmin=1) if untransform: x = self.untransform(x) return func(self, x, *args, **kwargs) - return wrapper + return wrapper_untransforms def gets_dependent_values(func): """Get dependent values of individual before calling function.""" @wraps(func) - def wrapper(self, x, *args, get_dependent_values=False, **kwargs): + def wrapper_gets_dependent_values(self, x, *args, get_dependent_values=False, **kwargs): if get_dependent_values: x = self.get_dependent_values(x) return func(self, x, *args, **kwargs) - return wrapper + return wrapper_gets_dependent_values def ensures2d(func): - """Make sure population is ndarray with ndmin=2.""" + """Ensure X array is an ndarray with ndmin=2.""" @wraps(func) - def wrapper(self, population, *args, **kwargs): - population = np.array(population, ndmin=2) - - return func(self, population, *args, **kwargs) + def wrapper_ensures2d( + self, + X: npt.ArrayLike, + *args, **kwargs + ) -> Any: + + # Convert to 2D population + X = np.array(X) + X_2d = np.array(X, ndmin=2) + + # Call and ensure results are 2D + Y = func(self, X_2d, *args, **kwargs) + Y_2d = Y.reshape((len(X_2d), -1)) + + # Reshape back to original length of X + if X.ndim == 1: + return Y_2d[0] + else: + return Y_2d - return wrapper + return wrapper_ensures2d def ensures_minimization(scores): """Convert maximization problems to minimization problems.""" def wrap(func): @wraps(func) - def wrapper(self, *args, ensure_minimization=False, **kwargs): + def wrapper_ensures_minimization(self, *args, ensure_minimization=False, **kwargs): s = func(self, *args, **kwargs) if ensure_minimization: @@ -170,7 +188,7 @@ def wrapper(self, *args, ensure_minimization=False, **kwargs): return s - return wrapper + return wrapper_ensures_minimization return wrap def transform_maximization(self, s, scores): @@ -261,17 +279,17 @@ def n_variables(self): return len(self.variables) @property - def independent_variables(self): + def independent_variables(self) -> list["OptimizationVariable"]: """list: Independent OptimizationVaribles.""" return list(filter(lambda var: var.is_independent, self.variables)) @property - def independent_variable_names(self): + def independent_variable_names(self) -> list[str]: """list: Independent optimization variable names.""" return [var.name for var in self.independent_variables] @property - def n_independent_variables(self): + def n_independent_variables(self) -> int: """int: Number of independent optimization variables.""" return len(self.independent_variables) @@ -555,13 +573,14 @@ def add_variable_dependency( vars = [self.variables_dict[indep] for indep in independent_variables] var.add_dependency(vars, transform) + @ensures2d @untransforms - def get_dependent_values(self, x): + def get_dependent_values(self, X_independent: npt.ArrayLike) -> np.ndarray: """Determine values of dependent optimization variables. Parameters ---------- - x : array_like + X_independent : array_like Value of the optimization variables in untransformed space. Raises @@ -571,32 +590,37 @@ def get_dependent_values(self, x): Returns ------- - x : np.ndarray + np.ndarray Value of all optimization variables in untransformed space. """ - if len(x) != self.n_independent_variables: + if X_independent.shape[1] != self.n_independent_variables: raise CADETProcessError( - f'Expected {self.n_independent_variables} value(s)' + f'Expected {self.n_independent_variables} value(s).' ) - variables = self.independent_variables + variable_values = np.zeros((len(X_independent), self.n_variables)) + independent_variables = self.independent_variables - for variable, value in zip(variables, x): - value = np.format_float_positional( - value, precision=variable.precision, fractional=False - ) - variable.value = float(value) + for i, x in enumerate(X_independent): + for indep_variable, indep_value in zip(independent_variables, x): + indep_value = np.format_float_positional( + indep_value, precision=indep_variable.precision, fractional=False + ) + indep_variable.value = float(indep_value) + + variable_values[i, :] = self.variable_values - return self.variable_values + return variable_values + @ensures2d @untransforms - def get_independent_values(self, x): + def get_independent_values(self, X: npt.ArrayLike) -> np.ndarray: """Remove dependent values from x. Parameters ---------- - x : array_like + X : array_like Value of all optimization variables. Works for transformed and untransformed space. @@ -611,20 +635,26 @@ def get_independent_values(self, x): Values of all independent optimization variables. """ - if len(x) != self.n_variables: + if X.shape[1] != self.n_variables: raise CADETProcessError( - f'Expected {self.n_variables} value(s), got {len(x)}' + f'Expected {self.n_variables} value(s).' ) - x_independent = [] + independent_values = np.zeros((len(X), self.n_independent_variables)) + variables = self.variables - for variable, value in zip(self.variables, x): - if variable.is_independent: - x_independent.append(value) + for i, x in enumerate(X): + x_independent = [] + for variable, value in zip(variables, x): + if variable.is_independent: + x_independent.append(value) - return np.array(x_independent) + independent_values[i, :] = np.array(x_independent) + + return independent_values @untransforms + @gets_dependent_values def set_variables(self, x, evaluation_objects=-1): """Set the values from the x-vector to the EvaluationObjects. @@ -656,23 +686,77 @@ def set_variables(self, x, evaluation_objects=-1): evaluate """ - values = self.get_dependent_values(x) - - for variable, value in zip(self.variables, values): + for variable, value in zip(self.variables, x): variable.set_value(value) - def _evaluate_individual(self, eval_funs, x, force=False): - """Call evaluation function function at point x. + def _evaluate_population( + self, + target_functions: list[callable], + X: npt.ArrayLike, + parallelization_backend: ParallelizationBackendBase | None = None, + force: bool = False + ) -> np.ndarray: + """ + Evaluate target functions for each individual in population. + + Parameters + ---------- + target_functions : list[callable] + List of evaluation targets (e.g. objectives). + X : npt.ArrayLike + Population to be evaluated in untransformed space. + parallelization_backend : ParallelizationBackendBase, optional + Adapter to backend for parallel evaluation of population. + By default, the individuals are evaluated sequentially. + force : bool + If True, do not use cached results. The default is False. + + Returns + ------- + np.ndarray + The results of the target functions. + + Raises + ------ + CADETProcessError + If dictcache is used for parallelized evaluation. + """ + if parallelization_backend is None: + parallelization_backend = SequentialBackend() + + if not self.cache.use_diskcache and parallelization_backend.n_cores != 1: + raise CADETProcessError( + "Cannot use dict cache for multiprocessing." + ) + + def target_wrapper(x): + results = self._evaluate_individual( + target_functions=target_functions, + x=x, + force=force, + ) + self.cache.close() - This function iterates over all functions in eval_funs (e.g. objectives). - To parallelize this, use _evaluate_population + return results + + results = parallelization_backend.evaluate(target_wrapper, X) + return np.array(results, ndmin=2) + + def _evaluate_individual( + self, + target_functions: list[callable], + x: npt.ArrayLike, + force=False, + ) -> np.ndarray: + """ + Evaluate target functions for set of parameters. Parameters ---------- - eval_funs : list of callables - Evaluation function. - x : array_like + x : npt.ArrayLike Value of all optimization variables in untransformed space. + target_functions: list[callable], + Evaluation functions. force : bool If True, do not use cached results. The default is False. @@ -691,7 +775,7 @@ def _evaluate_individual(self, eval_funs, x, force=False): x = np.asarray(x) results = np.empty((0,)) - for eval_fun in eval_funs: + for eval_fun in target_functions: try: value = self._evaluate(x, eval_fun, force) results = np.hstack((results, value)) @@ -704,53 +788,6 @@ def _evaluate_individual(self, eval_funs, x, force=False): return results - def _evaluate_population(self, eval_fun, population, force=False, parallelization_backend=None): - """Evaluate eval_fun functions for each point x in population. - - Parameters - ---------- - eval_fun : callable - Callable to be evaluated. - population : list - Population. - force : bool, optional - If True, do not use cached values. The default is False. - parallelization_backend : ParallelizationBackendBase, optional - Adapter to parallelization backend library for parallel evaluation of population. - - Raises - ------ - CADETProcessError - If dictcache is used for parallelized evaluation. - - Returns - ------- - results : np.ndarray - Results of the evaluation functions. - - """ - if parallelization_backend is None: - parallelization_backend = SequentialBackend() - - if not self.cache.use_diskcache and parallelization_backend.n_cores != 1: - raise CADETProcessError( - "Cannot use dict cache for multiprocessing." - ) - - def eval_fun_wrapper(ind): - results = eval_fun(ind, force=force) - self.cache.close() - - return results - - if parallelization_backend.n_cores != 1: - self.cache.close() - - results = parallelization_backend.evaluate(eval_fun_wrapper, population) - - return np.array(results, ndmin=2) - - @untransforms def _evaluate(self, x, func, force=False): """Iterate over all evaluation objects and evaluate at x. @@ -758,6 +795,7 @@ def _evaluate(self, x, func, force=False): ---------- x : array_like Value of the optimization variables in untransformed space. + Must include all variables, including the dependent variables. func : Evaluator or Objective, or Nonlinear Constraint, or Callback Evaluation function. force : bool @@ -1029,77 +1067,58 @@ def add_objective( ) self._objectives.append(objective) + @ensures2d @untransforms + @gets_dependent_values @ensures_minimization(scores='objectives') - def evaluate_objectives(self, x, force=False): - """Evaluate objective functions at point x. + def evaluate_objectives( + self, + X: npt.ArrayLike, + parallelization_backend: ParallelizationBackendBase | None = None, + force: bool = False, + ) -> np.ndarray: + """ + Evaluate objective functions for each individual x in population X. Parameters ---------- - x : array_like - Value of the optimization variables in untransformed space. + X : npt.ArrayLike + Population to be evaluated in untransformed space. + parallelization_backend : ParallelizationBackendBase, optional + Adapter to backend for parallel evaluation of population. + By default, the individuals are evaluated sequentially. force : bool If True, do not use cached results. The default is False. Returns ------- - f : np.ndarray - Values of the objective functions at point x. - - See Also - -------- - add_objective - evaluate_objectives_population - _call_evaluate_fun - _evaluate - - """ - self.logger.debug(f'Evaluate objectives at {x}.') - - f = self._evaluate_individual(self.objectives, x, force=force) - - return f - - @untransforms - @ensures2d - @ensures_minimization(scores='objectives') - def evaluate_objectives_population( - self, - population, - force=False, - parallelization_backend=None - ): - """Evaluate objective functions for each point x in population. - - Parameters - ---------- - population : list - Population. - force : bool, optional - If True, do not use cached values. The default is False. - parallelization_backend : RunnerBase, optional - Runner to use for the evaluation of the population in - sequential or parallel mode. - - Returns - ------- - results : np.ndarray - Objective function values. + np.ndarray + The optimization function values. See Also -------- - add_objective - evaluate_objectives + add_objectives + _evaluate_population _evaluate_individual _evaluate """ - results = self._evaluate_population( - self.evaluate_objectives, population, force, parallelization_backend + return self._evaluate_population( + target_functions=self.objectives, + X=X, + parallelization_backend=parallelization_backend, + force=force, ) - return results + def evaluate_objectives_population(self, *args, **kwargs): + warnings.warn( + 'This function is deprecated; use `evaluate_objectives` instead, which now ' + 'directly supports the evaluation of nd arrays.', + DeprecationWarning, stacklevel=2 + ) + self.evaluate_objectives(*args, *kwargs) @untransforms + @gets_dependent_values def objective_jacobian(self, x, ensure_minimization=False, dx=1e-3): """Compute jacobian of objective functions using finite differences. @@ -1285,165 +1304,136 @@ def add_nonlinear_constraint( ) self._nonlinear_constraints.append(nonlincon) + @ensures2d @untransforms - def evaluate_nonlinear_constraints(self, x, force=False): - """Evaluate nonlinear constraint functions at point x. + @gets_dependent_values + def evaluate_nonlinear_constraints( + self, + X: npt.ArrayLike, + parallelization_backend: ParallelizationBackendBase | None = None, + force: bool = False, + ) -> np.ndarray: + """ + Evaluate nonlinear constraint functions for each individual x in population X. Parameters ---------- - x : array_like - Value of the optimization variables in untransformed space. + X : npt.ArrayLike + Population to be evaluated in untransformed space. + parallelization_backend : ParallelizationBackendBase, optional + Adapter to backend for parallel evaluation of population. + By default, the individuals are evaluated sequentially. force : bool If True, do not use cached results. The default is False. Returns ------- - g : np.ndarray - Nonlinear constraint function values. + np.ndarray + The nonlinear constraint function values. See Also -------- add_nonlinear_constraint evaluate_nonlinear_constraints_violation - evaluate_nonlinear_constraints_population - _call_evaluate_fun - _evaluate - - """ - self.logger.debug(f'Evaluate nonlinear constraints at {x}.') - - g = self._evaluate_individual(self.nonlinear_constraints, x, force=False) - - return g - - @untransforms - @ensures2d - def evaluate_nonlinear_constraints_population(self, population, force=False, parallelization_backend=None): - """ - Evaluate nonlinear constraint for each point x in population. - - Parameters - ---------- - population : list - Population. - force : bool, optional - If True, do not use cached values. The default is False. - parallelization_backend : RunnerBase, optional - Runner to use for the evaluation of the population in - sequential or parallel mode. - - Returns - ------- - results : np.ndarray - Nonlinear constraints. - - See Also - -------- - add_nonlinear_constraint - evaluate_nonlinear_constraints + _evaluate_population _evaluate_individual _evaluate """ - results = self._evaluate_population( - self.evaluate_nonlinear_constraints, population, force, parallelization_backend + return self._evaluate_population( + target_functions=self.nonlinear_constraints, + X=X, + parallelization_backend=parallelization_backend, + force=force, ) - return results + def evaluate_nonlinear_constraints_population(self, *args, **kwargs): + warnings.warn( + 'This function is deprecated; use `evaluate_nonlinear_constraints` ' + 'instead, which now directly supports the evaluation of nd arrays.', + DeprecationWarning, stacklevel=2 + ) + self.evaluate_nonlinear_constraints(*args, *kwargs) + @ensures2d @untransforms - def evaluate_nonlinear_constraints_violation(self, x, force=False): - """Evaluate nonlinear constraints violation at point x. + @gets_dependent_values + def evaluate_nonlinear_constraints_violation( + self, + X: npt.ArrayLike, + parallelization_backend: ParallelizationBackendBase | None = None, + force: bool = False, + ) -> np.ndarray: + """ + Evaluate nonlinear constraint function violation for each x in population X. After evaluating the nonlinear constraint functions, the corresponding bounds are subtracted from the results. Parameters ---------- - x : array_like - Value of the optimization variables in untransformed space. + X : npt.ArrayLike + Population to be evaluated in untransformed space. + parallelization_backend : ParallelizationBackendBase, optional + Adapter to backend for parallel evaluation of population. + By default, the individuals are evaluated sequentially. force : bool If True, do not use cached results. The default is False. Returns ------- - cv : np.ndarray - Nonlinear constraints violation. + np.ndarray + The nonlinear constraint violation function values. See Also -------- add_nonlinear_constraint evaluate_nonlinear_constraints - evaluate_nonlinear_constraints_population - evaluate_nonlinear_constraints_violation_population - _call_evaluate_fun + _evaluate_population + _evaluate_individual _evaluate - """ - self.logger.debug(f'Evaluate nonlinear constraints violation at {x}.') - factors = [] for constr in self.nonlinear_constraints: factor = -1 if constr.comparison_operator == 'ge' else 1 factors += constr.n_total_metrics * [factor] - g = self._evaluate_individual(self.nonlinear_constraints, x, force=False) - g_transformed = np.multiply(factors, g) + G = self._evaluate_population( + target_functions=self.nonlinear_constraints, + X=X, + parallelization_backend=parallelization_backend, + force=force, + ) + G_transformed = np.multiply(factors, G) bounds_transformed = np.multiply(factors, self.nonlinear_constraints_bounds) - cv = g_transformed - bounds_transformed - - return cv - - @untransforms - @ensures2d - def evaluate_nonlinear_constraints_violation_population( - self, population, force=False, parallelization_backend=None): - """ - Evaluate nonlinear constraints violation for each point x in population. - - After evaluating the nonlinear constraint functions, the corresponding - bounds are subtracted from the results. - - Parameters - ---------- - population : list - Population. - force : bool, optional - If True, do not use cached values. The default is False. - parallelization_backend : RunnerBase, optional - Runner to use for the evaluation of the population in - sequential or parallel mode. - - Returns - ------- - results : np.ndarray - Nonlinear constraints violation. - - See Also - -------- - add_nonlinear_constraint - evaluate_nonlinear_constraints_violation - evaluate_nonlinear_constraints - evaluate_nonlinear_constraints_population - _evaluate_individual - _evaluate + return G_transformed - bounds_transformed - """ - results = self._evaluate_population( - self.evaluate_nonlinear_constraints_violation, population, force, parallelization_backend + def evaluate_nonlinear_constraints_violation_population(self, *args, **kwargs): + warnings.warn( + 'This function is deprecated; use ' + '`evaluate_nonlinear_constraints_violation` instead, which now directly ' + 'supports the evaluation of nd arrays.', + DeprecationWarning, stacklevel=2 ) - - return results + self.evaluate_nonlinear_constraints_violation(*args, *kwargs) @untransforms - def check_nonlinear_constraints(self, x): + @gets_dependent_values + def check_nonlinear_constraints( + self, + x: npt.ArrayLike, + cv_nonlincon_tol: Optional[float | np.ndarray] = 0.0 + ) -> bool: """Check if all nonlinear constraints are met. Parameters ---------- - x : array_like + x : npt.ArrayLike Value of the optimization variables in untransformed space. + cv_nonlincon_tol : float or np.ndarray, optional + Tolerance for checking the nonlinear constraints. If a scalar is provided, + the same value is used for all constraints. Default is 0.0. Returns ------- @@ -1451,14 +1441,28 @@ def check_nonlinear_constraints(self, x): True if all nonlinear constraints violation are smaller or equal to zero, False otherwise. + Raises + ------ + ValueError + If length of `cv_nonlincon_tol` does not match the number of constraints. """ cv = np.array(self.evaluate_nonlinear_constraints_violation(x)) - if np.any(cv > 0): + if np.isscalar(cv_nonlincon_tol): + cv_nonlincon_tol = np.repeat(cv_nonlincon_tol, self.n_nonlinear_constraints) + + if len(cv_nonlincon_tol) != self.n_nonlinear_constraints: + raise ValueError( + f"Length of `cv_nonlincon_tol` ({len(cv_nonlincon_tol)}) does not " + f"match number of constraints ({self.n_nonlinear_constraints})." + ) + + if np.any(cv > cv_nonlincon_tol): return False return True @untransforms + @gets_dependent_values def nonlinear_constraint_jacobian(self, x, dx=1e-3): """Compute jacobian of the nonlinear constraints at point x. @@ -1598,74 +1602,48 @@ def add_callback( ) self._callbacks.append(callback) - def evaluate_callbacks(self, ind, current_iteration=0, force=False): - """Evaluate callback functions at point x. + def evaluate_callbacks( + self, + population: Population | Individual | npt.ArrayLike, + current_iteration: int = 0, + parallelization_backend: ParallelizationBackendBase | None = None, + force: bool = False, + ) -> NoReturn: + """ + Evaluate callback functions for each individual x in population X. Parameters ---------- - ind : Individual - Individual to be evalauted. + population : Population | Individual | npt.ArrayLike + Population to be evaluated. + If an Individual is passed, a new population will be created. + If a numpy array is passed, a new population will be created, assuming the + values are independent values in untransformed space. current_iteration : int, optional Current iteration step. This value is used to determine whether the evaluation of callbacks should be skipped according to their evaluation frequency. The default is 0, indicating the callbacks will be evaluated regardless of the specified frequency. + parallelization_backend : ParallelizationBackendBase, optional + Adapter to backend for parallel evaluation of population. + By default, the individuals are evaluated sequentially. force : bool If True, do not use cached results. The default is False. See Also -------- - evaluate_callbacks_population + add_callback + _evaluate_population + _evaluate_individual _evaluate - """ - self.logger.debug(f'evaluate callbacks at {ind.x}') + if isinstance(population, Individual): + ind = population + population = Population() + population.add_individual(ind) + elif isinstance(population, (list, np.ndarray)): + population = self.create_population(population) - for callback in self.callbacks: - if not ( - current_iteration == 'final' - or - current_iteration % callback.frequency == 0): - continue - - callback._ind = ind - callback._current_iteration = current_iteration - - try: - self._evaluate(ind.x_transformed, callback, force, untransform=True) - except CADETProcessError: - self.logger.warning( - f'Evaluation of {callback} failed at {ind.x}.' - ) - - def evaluate_callbacks_population( - self, - population, - current_iteration=0, - force=False, - parallelization_backend=None - ): - """Evaluate callbacks for each individual ind in population. - - Parameters - ---------- - population : list - Population. - current_iteration : int, optional - Current iteration step. This value is used to determine whether the - evaluation of callbacks is skipped according to their evaluation frequency. - The default is 0, indicating it will definitely be evaluated. - force : bool, optional - If True, do not use cached values. The default is False. - parallelization_backend : RunnerBase, optional - Runner to use for the evaluation of the population in - sequential or parallel mode. - - See Also - -------- - add_callback - evaluate_callbacks - """ if parallelization_backend is None: parallelization_backend = SequentialBackend() @@ -1674,15 +1652,32 @@ def evaluate_callbacks_population( "Cannot use dict cache for multiprocessing." ) - def eval_fun(ind): - self.evaluate_callbacks( - ind, - current_iteration, - force=force - ) - self.cache.close() + def evaluate_callbacks(ind): + for callback in self.callbacks: + if not ( + current_iteration == 'final' + or + current_iteration % callback.frequency == 0): + continue - parallelization_backend.evaluate(eval_fun, population) + callback._ind = ind + callback._current_iteration = current_iteration + + try: + self._evaluate(ind.x, callback, force) + except CADETProcessError as e: + self.logger.warning( + f'Evaluation of {callback} failed at {ind.x} with Error "{e}".' + ) + parallelization_backend.evaluate(evaluate_callbacks, population) + + def evaluate_callbacks_population(self, *args, **kwargs): + warnings.warn( + 'This function is deprecated; use `evaluate_callbacks` instead, which now ' + 'directly supports the evaluation of nd arrays.', + DeprecationWarning, stacklevel=2 + ) + self.evaluate_callbacks(*args, *kwargs) @property def meta_scores(self): @@ -1793,68 +1788,55 @@ def add_meta_score( ) self._meta_scores.append(meta_score) + @ensures2d @untransforms + @gets_dependent_values @ensures_minimization(scores='meta_scores') - def evaluate_meta_scores(self, x, force=False): - """Evaluate meta functions at point x. + def evaluate_meta_scores( + self, + X: npt.ArrayLike, + parallelization_backend: ParallelizationBackendBase | None = None, + force: bool = False, + ) -> np.ndarray: + """ + Evaluate meta scores for each individual x in population X. Parameters ---------- - x : array_like - Value of the optimization variables in untransformed space. + X : npt.ArrayLike + Population to be evaluated in untransformed space. + parallelization_backend : ParallelizationBackendBase, optional + Adapter to backend for parallel evaluation of population. + By default, the individuals are evaluated sequentially. force : bool If True, do not use cached results. The default is False. Returns ------- - m : np.ndarray - Meta scores. - - See Also - -------- - add_meta_score - evaluate_nonlinear_constraints_population - _call_evaluate_fun - _evaluate - """ - self.logger.debug(f'Evaluate meta functions at {x}.') - - m = self._evaluate_individual(self.meta_scores, x, force=force) - - return m - - @untransforms - @ensures2d - @ensures_minimization(scores='meta_scores') - def evaluate_meta_scores_population(self, population, force=False, parallelization_backend=None): - """Evaluate meta score functions for each point x in population. - - Parameters - ---------- - population : list - Population. - force : bool, optional - If True, do not use cached values. The default is False. - parallelization_backend : RunnerBase, optional - Runner to use for the evaluation of the population in - sequential or parallel mode. - Returns - ------- - results : np.ndarray - Meta scores. + np.ndarray + The meta scores. See Also -------- add_meta_score - evaluate_meta_scores + _evaluate_population _evaluate_individual _evaluate """ - results = self._evaluate_population( - self.evaluate_meta_scores, population, force, parallelization_backend + return self._evaluate_population( + target_functions=self.meta_scores, + X=X, + parallelization_backend=parallelization_backend, + force=force, ) - return results + def evaluate_meta_scores_population(self, *args, **kwargs): + warnings.warn( + 'This function is deprecated; use `evaluate_meta_scores` instead, which now ' + 'directly supports the evaluation of nd arrays.', + DeprecationWarning, stacklevel=2 + ) + self.evaluate_meta_scores(*args, *kwargs) @property def multi_criteria_decision_functions(self): @@ -1912,13 +1894,17 @@ def add_multi_criteria_decision_function(self, decision_function, name=None): meta_score = MultiCriteriaDecisionFunction(decision_function, name) self._multi_criteria_decision_functions.append(meta_score) - def evaluate_multi_criteria_decision_functions(self, pareto_population): - """Evaluate evaluate multi criteria decision functions. + def evaluate_multi_criteria_decision_functions( + self, + pareto_population: Population, + ) -> np.ndarray: + """ + Evaluate evaluate multi criteria decision functions. Parameters ---------- pareto_population : Population - Pareto optimal solution + Pareto optimal solution. Returns ------- @@ -1928,7 +1914,6 @@ def evaluate_multi_criteria_decision_functions(self, pareto_population): See Also -------- add_multi_criteria_decision_function - add_multi_criteria_decision_function """ self.logger.debug('Evaluate multi criteria decision functions.') @@ -2027,8 +2012,8 @@ def upper_bounds_independent_transformed(self): @untransforms @gets_dependent_values - def check_bounds(self, x): - """Check if all bound constraints are kept. + def evaluate_bounds(self, x): + """Calculate bound violation. Parameters ---------- @@ -2037,17 +2022,71 @@ def check_bounds(self, x): Returns ------- - flag : Bool - True, if all values are within the bounds. False otherwise. + constraints: np.ndarray + Value of the linear constraints at point x + + See Also + -------- + check_bounds + lower_bounds + upper_bounds + + """ + # Calculate the residuals for lower bounds + lower_residual = np.maximum(0, self.lower_bounds - x) + + # Calculate the residuals for upper bounds + upper_residual = np.maximum(0, x - self.upper_bounds) + + # Combine the residuals + residual = lower_residual + upper_residual + + return residual + + @untransforms + @gets_dependent_values + def check_bounds( + self, + x: npt.ArrayLike, + cv_bounds_tol: Optional[float | np.ndarray] = 0.0 + ) -> bool: + """Check if all bound constraints are kept. + + Parameters + ---------- + x : npt.ArrayLike + Value of the optimization variables in untransformed space. + cv_bounds_tol : float or np.ndarray, optional + Tolerance for checking the bound constraints. If a scalar is provided, + the same value is used for all variables. Default is 0.0. + + Returns + ------- + flag : bool + True if all nonlinear constraints violation are smaller or equal to zero, + False otherwise. + Raises + ------ + ValueError + If length of `cv_bounds_tol` does not match the number of variables. """ flag = True values = np.array(x, ndmin=1) - if np.any(np.less(values, self.lower_bounds)): + if np.isscalar(cv_bounds_tol): + cv_bounds_tol = np.repeat(cv_bounds_tol, self.n_variables) + + if len(cv_bounds_tol) != self.n_variables: + raise ValueError( + f"Length of `cv_bounds_tol` ({len(cv_bounds_tol)}) does not match " + f"number of variables ({self.n_variables})." + ) + + if np.any(np.less(values, self.lower_bounds - cv_bounds_tol)): flag = False - if np.any(np.greater(values, self.upper_bounds)): + if np.any(np.greater(values, self.upper_bounds + cv_bounds_tol)): flag = False return flag @@ -2103,7 +2142,7 @@ def add_linear_constraint(self, opt_vars, lhs=1.0, b=0.0): raise CADETProcessError('Variable not in variables.') if np.isscalar(lhs): - lhs = np.ones(len(opt_vars)) + lhs = lhs * np.ones(len(opt_vars)) if len(lhs) != len(opt_vars): raise CADETProcessError( @@ -2216,7 +2255,7 @@ def A_independent_transformed(self): @property def b(self): - """list: Vector form of linear constraints. + """np.ndarray: Vector form of linear constraints. See Also -------- @@ -2302,30 +2341,52 @@ def evaluate_linear_constraints(self, x): @untransforms @gets_dependent_values - def check_linear_constraints(self, x): + def check_linear_constraints( + self, + x: npt.ArrayLike, + cv_lincon_tol: Optional[float | np.ndarray] = 0.0 + ) -> bool: """Check if linear inequality constraints are met at point x. Parameters ---------- - x : array_like + x : npt.ArrayLike Value of the optimization variables in untransformed space. + cv_lincon_tol : float or np.ndarray, optional + Tolerance for checking the linear constraints. If a scalar is provided, + the same value is used for all constraints. Default is 0.0. Returns ------- flag : bool True if linear inequality constraints are met. False otherwise. + Raises + ------ + ValueError + If the length of `cv_lincon_tol` does not match the number of constraints. + See Also -------- linear_constraints evaluate_linear_constraints A b - """ flag = True - if np.any(self.evaluate_linear_constraints(x) > 0): + linear_constraints_values = self.evaluate_linear_constraints(x) + + if np.isscalar(cv_lincon_tol): + cv_lincon_tol = np.repeat(cv_lincon_tol, self.n_linear_constraints) + + if len(cv_lincon_tol) != self.n_linear_constraints: + raise ValueError( + f"Length of `cv_lincon_tol` ({len(cv_lincon_tol)}) does not match " + f"number of constraints ({self.n_linear_constraints})." + ) + + if np.any(linear_constraints_values > cv_lincon_tol): flag = False return flag @@ -2381,7 +2442,7 @@ def add_linear_equality_constraint(self, opt_vars, lhs=1, beq=0, eps=0.0): raise CADETProcessError('Variable not in variables.') if np.isscalar(lhs): - lhs = len(opt_vars) * [1] + lhs = lhs * np.ones(len(opt_vars)) if len(lhs) != len(opt_vars): raise CADETProcessError( @@ -2392,7 +2453,7 @@ def add_linear_equality_constraint(self, opt_vars, lhs=1, beq=0, eps=0.0): lineqcon['opt_vars'] = opt_vars lineqcon['lhs'] = lhs lineqcon['beq'] = beq - lineqcon['eps_eq'] = float(eps) + lineqcon['eps'] = float(eps) self._linear_equality_constraints.append(lineqcon) @@ -2493,7 +2554,7 @@ def Aeq_independent_transformed(self): @property def beq(self): - """list: Vector form of linear equality constraints. + """np.ndarray: Vector form of linear equality constraints. See Also -------- @@ -2502,10 +2563,9 @@ def beq(self): remove_linear_equality_constraint """ - beq = np.zeros((len(self.linear_equality_constraints),)) - beq = [lineqcon['beq'] for lineqcon in self.linear_equality_constraints] + beq = [lincon['beq'] for lincon in self.linear_equality_constraints] - return beq + return np.array(beq) @property def beq_transformed(self): @@ -2552,7 +2612,7 @@ def beq_transformed(self): return beq_t @property - def eps_eq(self): + def eps_lineq(self): """np.array: Relaxation tolerance for linear equality constraints. See Also @@ -2560,7 +2620,7 @@ def eps_eq(self): add_linear_inequality_constraint """ return np.array([ - lineqcon['eps_eq'] for lineqcon in self.linear_equality_constraints + lineqcon['eps'] for lineqcon in self.linear_equality_constraints ]) @untransforms @@ -2591,77 +2651,96 @@ def evaluate_linear_equality_constraints(self, x): @untransforms @gets_dependent_values - def check_linear_equality_constraints(self, x): + def check_linear_equality_constraints( + self, + x: npt.ArrayLike, + cv_lineq_tol: Optional[float | np.ndarray] = 0.0 + ) -> bool: """Check if linear equality constraints are met at point x. Parameters ---------- - x : array_like + x : npt.ArrayLike Value of the optimization variables in untransformed space. + cv_lineq_tol : float or np.ndarray, optional + Tolerance for checking linear equality constraints. If a scalar is provided, + the same value is used for all constraints. Default is 0.0. Returns ------- flag : bool True if linear equality constraints are met. False otherwise. + Raises + ------ + ValueError + If length of `cv_lineq_tol` does not match the number of constraints. """ flag = True lhs = self.evaluate_linear_equality_constraints(x) - if np.any(np.abs(lhs) >= self.eps_eq): + + if np.isscalar(cv_lineq_tol): + cv_lineq_tol = np.repeat(cv_lineq_tol, len(lhs)) + + if len(cv_lineq_tol) != self.n_linear_equality_constraints: + raise ValueError( + f"Length of `cv_lineq_tol` ({len(cv_lineq_tol)}) does not match " + f"number of constraints ({self.n_linear_equality_constraints})." + ) + + if np.any(np.abs(lhs) > self.eps_lineq - cv_lineq_tol): flag = False return flag - def transform(self, x_independent): - """Transform the independent optimization variables from untransformed parameter space. + @ensures2d + def transform(self, X_independent: npt.ArrayLike) -> np.ndarray: + """Transform independent optimization variables from untransformed parameter space. Parameters ---------- - x_independent : list - Value of the independent optimization variables in untransformed space. + x_independent : np.ndarray + Independent optimization variables in untransformed space. Returns ------- np.ndarray Optimization variables in transformed parameter space. """ - x_independent = np.array(x_independent) - x_2d = np.array(x_independent, ndmin=2) - transform = np.zeros(x_2d.shape) + transform = np.zeros(X_independent.shape) - for i, ind in enumerate(x_2d): + for i, ind in enumerate(X_independent): transform[i, :] = [ var.transform_fun(value) for value, var in zip(ind, self.independent_variables) ] - return transform.reshape(x_independent.shape) + return transform - def untransform(self, x_transformed): + @ensures2d + def untransform(self, X_transformed: npt.ArrayLike) -> np.ndarray: """Untransform the optimization variables from transformed parameter space. Parameters ---------- - x_transformed : list - Optimization variables in transformed parameter space. + X_transformed : npt.ArrayLike + Independent optimization variables in transformed parameter space. Returns ------- np.ndarray Optimization variables in untransformed parameter space. """ - x_transformed = np.array(x_transformed) - x_transformed_2d = np.array(x_transformed, ndmin=2) - untransform = np.zeros(x_transformed_2d.shape) + untransform = np.zeros(X_transformed.shape) - for i, ind in enumerate(x_transformed_2d): + for i, ind in enumerate(X_transformed): untransform[i, :] = [ var.untransform_fun(value) for value, var in zip(ind, self.independent_variables) ] - return untransform.reshape(x_transformed.shape) + return untransform @property def cached_steps(self): @@ -2688,9 +2767,9 @@ def cache_directory(self): def cache_directory(self, cache_directory): self._cache_directory = cache_directory - def setup_cache(self): + def setup_cache(self, n_shards=1): """Setup cache to store (intermediate) results.""" - self.cache = ResultsCache(self.use_diskcache, self.cache_directory) + self.cache = ResultsCache(self.use_diskcache, self.cache_directory, n_shards) def delete_cache(self, reinit=False): """Delete cache with (intermediate) results.""" @@ -2701,9 +2780,19 @@ def delete_cache(self, reinit=False): if reinit: self.setup_cache() - def prune_cache(self, tag=None): - """Prune cache with (intermediate) results.""" - self.cache.prune(tag) + def prune_cache(self, tag=None, close=True): + """ + Prune cache with (intermediate) results. + + Parameters + ---------- + tag : str + Tag to be removed. The default is 'temp'. + close : bool, optional + If True, database will be closed after operation. The default is True. + + """ + self.cache.prune(tag, close) def create_hopsy_problem( self, @@ -2935,7 +3024,8 @@ def create_initial_values( ind = self.get_dependent_values(ind) - if not self.check_individual(ind, silent=True): + if not self.check_individual( + ind, check_nonlinear_constraints=False, silent=True): continue if not include_dependent_variables: @@ -2950,12 +3040,11 @@ def create_initial_values( def create_individual( self, x: np.ndarray, - f: np.ndarray = None, + f: np.ndarray | None = None, + f_min: np.ndarray | None = None, g: np.ndarray | None = None, + cv_nonlincon: np.ndarray | None = None, m: np.ndarray | None = None, - f_min: np.ndarray | None = None, - cv: np.ndarray | None = None, - cv_tol: float = 0., m_min: np.ndarray | None = None, ) -> Individual: """ @@ -2967,16 +3056,14 @@ def create_individual( Variable values in untransformed space. f : np.ndarray Objective values. + f_min : np.ndarray + Minimized objective values. g : np.ndarray Nonlinear constraint values. + cv_nonlincon : np.ndarray + Nonlinear constraints violation. m : np.ndarray Meta score values. - f_min : np.ndarray - Minimized objective values. - cv : np.ndarray - Nonlinear constraints violation. - cv_tol : float - Tolerance for constraints violation. m_min : np.ndarray Minimized meta score values. @@ -2988,51 +3075,61 @@ def create_individual( x_indep = self.get_independent_values(x) x_transformed = self.transform(x_indep) + cv_bounds = self.evaluate_bounds(x) + cv_lincon = self.evaluate_linear_constraints(x) + cv_lineqcon = np.abs(self.evaluate_linear_equality_constraints(x)) + ind = Individual( - x, f, g, m, x_transformed, f_min, cv, cv_tol, m_min, - self.independent_variable_names, - self.objective_labels, - self.nonlinear_constraint_labels, - self.meta_score_labels, - self.variable_names, + x=x, + x_transformed=x_transformed, + cv_bounds=cv_bounds, + cv_lincon=cv_lincon, + cv_lineqcon=cv_lineqcon, + f=f, + f_min=f_min, + g=g, + cv_nonlincon=cv_nonlincon, + m=m, + m_min=m_min, + independent_variable_names=self.independent_variable_names, + objective_labels=self.objective_labels, + nonlinear_constraint_labels=self.nonlinear_constraint_labels, + meta_score_labels=self.meta_score_labels, + variable_names=self.variable_names, ) return ind - @ensures2d @untransforms @gets_dependent_values def create_population( self, - X: np.ndarray, - F: np.ndarray = None, - G: np.ndarray | None = None, - M: np.ndarray | None = None, - F_min: np.ndarray | None = None, - CV: np.ndarray | None = None, - cv_tol: float = 0., - M_min: np.ndarray | None = None, + X: npt.ArrayLike, + F: npt.ArrayLike = None, + F_min: npt.ArrayLike | None = None, + G: npt.ArrayLike | None = None, + CV_nonlincon: npt.ArrayLike | None = None, + M: npt.ArrayLike | None = None, + M_min: npt.ArrayLike | None = None, ) -> Population: """ Create new population from data. Parameters ---------- - X : np.ndarray + X : npt.ArrayLike Variable values in untransformed space. - F : np.ndarray + F : npt.ArrayLike Objective values. - G : np.ndarray - Nonlinear constraint values. - M : np.ndarray - Meta score values. - F_min : np.ndarray + F_min : npt.ArrayLike Minimized objective values. - CV : np.ndarray + G : npt.ArrayLike + Nonlinear constraint values. + CV_nonlincon : npt.ArrayLike Nonlinear constraints violation. - cv_tol : float - Tolerance for constraints violation. - M_min : np.ndarray + M : npt.ArrayLike + Meta score values. + M_min : npt.ArrayLike Minimized meta score values. Returns @@ -3040,39 +3137,53 @@ def create_population( Population The newly created population. """ + X = np.array(X, ndmin=2) + if F is None: F = len(X) * [None] else: F = np.array(F, ndmin=2) + if F_min is None: + F_min = F + else: + F_min = np.array(F_min, ndmin=2) + if G is None: G = len(X) * [None] else: G = np.array(G, ndmin=2) + if CV_nonlincon is None: + CV_nonlincon = G + else: + CV_nonlincon = np.array(CV_nonlincon, ndmin=2) + if M is None: M = len(X) * [None] else: M = np.array(M, ndmin=2) - if F_min is None: - F_min = F - else: - F_min = np.array(F_min, ndmin=2) - - if CV is None: - CV = G - else: - CV = np.array(CV, ndmin=2) - if M_min is None: M_min = M else: M_min = np.array(M_min, ndmin=2) pop = Population() - for x, f, g, m, f_min, cv, m_min in zip(X, F, G, M, F_min, CV, M_min): - ind = self.create_individual(x, f, g, m, f_min, cv, cv_tol, m_min) + for ( + x, f, f_min, g, cv_nonlincon, m, m_min + ) in zip( + X, F, F_min, G, CV_nonlincon, M, M_min + ): + ind = self.create_individual( + x, + f=f, + f_min=f_min, + g=g, + cv_nonlincon=cv_nonlincon, + m=m, + m_min=m_min, + ) pop.add_individual(ind) return pop @@ -3185,35 +3296,68 @@ def check_config(self, ignore_linear_constraints=False): @untransforms @gets_dependent_values - def check_individual(self, x, silent=False): - """Check if individual is valid. + def check_individual( + self, + x: npt.ArrayLike, + cv_bounds_tol: Optional[float | np.ndarray] = 0.0, + cv_lincon_tol: Optional[float | np.ndarray] = 0.0, + cv_lineqcon_tol: Optional[float | np.ndarray] = 0.0, + check_nonlinear_constraints: bool = False, + cv_nonlincon_tol: Optional[float | np.ndarray] = 0.0, + silent: bool = False, + ) -> bool: + """ + Check if individual is valid. Parameters ---------- - x : array_like + x : npt.ArrayLike Value of the optimization variables in untransformed space. + cv_bounds_tol : float or np.ndarray, optional + Tolerance for checking the bound constraints. Default is 0.0. + cv_lincon_tol : float or np.ndarray, optional + Tolerance for checking the linear inequality constraints. Default is 0.0. + cv_lineqcon_tol : float or np.ndarray, optional + Tolerance for checking the linear equality constraints. Default is 0.0. + check_nonlinear_constraints : bool, optional + If True, also check nonlinear constraints. Note that depending on the + nonlinear constraints, this can be an expensive operation. + The default is False. + cv_nonlincon_tol : float or np.ndarray, optional + Tolerance for checking the nonlinear constraints. Default is 0.0. + silent : bool, optional + If True, suppress warnings. The default is False. Returns ------- bool - True if the individual is valid correctly, False otherwise. - + True if the individual is valid, False otherwise. """ flag = True - if not self.check_bounds(x): + if not self.check_bounds(x, cv_bounds_tol): if not silent: warnings.warn("Individual does not satisfy bounds.") flag = False - if not self.check_linear_constraints(x): + + if not self.check_linear_constraints(x, cv_lincon_tol): if not silent: warnings.warn("Individual does not satisfy linear constraints.") flag = False - if not self.check_linear_equality_constraints(x): + + if not self.check_linear_equality_constraints(x, cv_lineqcon_tol): if not silent: - warnings.warn("Individual does not satisfy linear equality constraints.") + warnings.warn( + "Individual does not satisfy linear equality constraints." + ) flag = False + if check_nonlinear_constraints: + if not self.check_nonlinear_constraints(x, cv_nonlincon_tol): + flag = False + if not silent: + warnings.warn("Individual does not satisfy nonlinear constraints.") + return flag def __str__(self): diff --git a/CADETProcess/optimization/optimizer.py b/CADETProcess/optimization/optimizer.py index f2239a0c..269e5880 100644 --- a/CADETProcess/optimization/optimizer.py +++ b/CADETProcess/optimization/optimizer.py @@ -54,9 +54,18 @@ class OptimizerBase(Structure): progress_frequency : int Number of generations after which the optimizer reports progress. The default is 1. - cv_tol : float - Tolerance for constraint violation. - The default is 1e-6. + cv_bounds_tol : float + Tolerance for bounds constraint violation. + The default is 0.0. + cv_lincon_tol : float + Tolerance for linear constraints violation. + The default is 0.0. + cv_lineqcon_tol : float + Tolerance for linear equality constraints violation. + The default is 0.0. + cv_nonlincon_tol : float + Tolerance for nonlinear constraints violation. + The default is 0.0. similarity_tol : UnsignedFloat, optional Tolerance for individuals to be considered similar. Similar items are removed from the Pareto front to limit its size. @@ -85,11 +94,15 @@ class OptimizerBase(Structure): ignore_linear_constraints_config = False - progress_frequency = RangedInteger(lb=1, default=1) + progress_frequency = RangedInteger(lb=1) x_tol = UnsignedFloat() f_tol = UnsignedFloat() - cv_tol = UnsignedFloat(default=0) + + cv_bounds_tol = UnsignedFloat(default=0.0) + cv_lineqcon_tol = UnsignedFloat(default=0.0) + cv_lincon_tol = UnsignedFloat(default=0.0) + cv_nonlincon_tol = UnsignedFloat(default=0.0) n_max_iter = UnsignedInteger(default=100000) n_max_evals = UnsignedInteger(default=100000) @@ -99,8 +112,15 @@ class OptimizerBase(Structure): _general_options = [ 'progress_frequency', - 'x_tol', 'f_tol', 'cv_tol', 'similarity_tol', - 'n_max_iter', 'n_max_evals', + 'x_tol', + 'f_tol', + 'cv_bounds_tol', + 'cv_lincon_tol', + 'cv_lineqcon_tol', + 'cv_nonlincon_tol', + 'n_max_iter', + 'n_max_evals', + 'similarity_tol', ] def __init__(self, *args, **kwargs): @@ -110,7 +130,7 @@ def __init__(self, *args, **kwargs): def optimize( self, - optimization_problem, + optimization_problem: OptimizationProblem, x0=None, save_results=True, results_directory=None, @@ -188,7 +208,6 @@ def optimize( optimization_problem=optimization_problem, optimizer=self, similarity_tol=self.similarity_tol, - cv_tol=self.cv_tol, ) if save_results: @@ -235,7 +254,7 @@ def optimize( self.callbacks_dir = callbacks_dir if reinit_cache: - self.optimization_problem.setup_cache() + self.optimization_problem.setup_cache(self.n_cores) if x0 is not None: flag, x0 = self.check_x0(optimization_problem, x0) @@ -404,12 +423,21 @@ def check_x0(self, optimization_problem, x0): if n_dependent_variables > 0 and x0.shape[1] == n_variables: x0 = [optimization_problem.get_independent_values(ind) for ind in x0] warnings.warn( - "x0 contains dependent values. Will recompute dependencies for consistency." + "x0 contains dependent values. " + "Will recompute dependencies for consistency." ) x0 = np.array(x0) for x in x0: - if not optimization_problem.check_individual(x, get_dependent_values=True): + if not optimization_problem.check_individual( + x, + get_dependent_values=True, + cv_bounds_tol=self.cv_bounds_tol, + cv_lincon_tol=self.cv_lincon_tol, + cv_lineqcon_tol=self.cv_lineqcon_tol, + check_nonlinear_constraints=False, + silent=True, + ): flag = False break @@ -420,44 +448,55 @@ def check_x0(self, optimization_problem, x0): return flag, x0 - def _create_population(self, X_transformed, F, F_min, G, CV): + def _create_population(self, X_transformed, F, F_min, G, CV_nonlincon): """Create new population from current generation for post procesing.""" X_transformed = np.array(X_transformed, ndmin=2) F = np.array(F, ndmin=2) F_min = np.array(F_min, ndmin=2) G = np.array(G, ndmin=2) - CV = np.array(CV, ndmin=2) + CV_nonlincon = np.array(CV_nonlincon, ndmin=2) if self.optimization_problem.n_meta_scores > 0: - M_min = self.optimization_problem.evaluate_meta_scores_population( + M_min = self.optimization_problem.evaluate_meta_scores( X_transformed, untransform=True, ensure_minimization=True, parallelization_backend=self.parallelization_backend, ) - M = self.optimization_problem.transform_maximization(M_min, scores='meta_scores') + M = self.optimization_problem.transform_maximization( + M_min, scores='meta_scores' + ) else: - M_min = len(X_transformed)*[None] - M = len(X_transformed)*[None] + M_min = None + M = None if self.optimization_problem.n_nonlinear_constraints == 0: - G = len(X_transformed)*[None] - CV = len(X_transformed)*[None] + G = None + CV_nonlincon = None - population = Population() - for x_transformed, f, f_min, g, cv, m, m_min in zip(X_transformed, F, F_min, G, CV, M, M_min): - x = self.optimization_problem.get_dependent_values( - x_transformed, untransform=True - ) - ind = Individual( - x, f, g, m, x_transformed, f_min, cv, self.cv_tol, m_min, - self.optimization_problem.independent_variable_names, - self.optimization_problem.objective_labels, - self.optimization_problem.nonlinear_constraint_labels, - self.optimization_problem.meta_score_labels, - self.optimization_problem.variable_names, + X = self.optimization_problem.get_dependent_values( + X_transformed, untransform=True + ) + population = self.optimization_problem.create_population( + X, + F=F, + F_min=F_min, + G=G, + CV_nonlincon=CV_nonlincon, + M=M, + M_min=M_min, + ) + + for ind in population: + ind.is_feasible = self.optimization_problem.check_individual( + ind.x, + cv_bounds_tol=self.cv_bounds_tol, + cv_lincon_tol=self.cv_lincon_tol, + cv_lineqcon_tol=self.cv_lineqcon_tol, + check_nonlinear_constraints=True, + cv_nonlincon_tol=self.cv_nonlincon_tol, + silent=True, ) - population.add_individual(ind) return population @@ -505,13 +544,14 @@ def _evaluate_callbacks(self, current_generation, sub_dir=None): for callback in self.optimization_problem.callbacks: if self.optimization_problem.n_callbacks > 1: _callbacks_dir = callbacks_dir / str(callback) + _callbacks_dir.mkdir(exist_ok=True, parents=True) else: _callbacks_dir = callbacks_dir callback.cleanup(_callbacks_dir, current_generation) callback._callbacks_dir = _callbacks_dir - self.optimization_problem.evaluate_callbacks_population( + self.optimization_problem.evaluate_callbacks( self.results.meta_front, current_generation, parallelization_backend=self.parallelization_backend, @@ -525,7 +565,7 @@ def _log_results(self, current_generation): message = f'x: {ind.x}, f: {ind.f}' if self.optimization_problem.n_nonlinear_constraints > 0: - message += f', g: {ind.g}' + message += f', cv: {ind.cv_nonlincon}' if self.optimization_problem.n_meta_scores > 0: message += f', m: {ind.m}' @@ -536,7 +576,7 @@ def run_post_processing( X_transformed, F_minimized, G, - CV, + CV_nonlincon, current_generation, X_opt_transformed=None ): @@ -556,7 +596,7 @@ def run_post_processing( This assumes that all objective function values are minimized. G : list Nonlinear constraint function values of generation. - CV : list + CV_nonlincon : list Nonlinear constraints violation of of generation. current_generation : int Current generation. @@ -565,8 +605,12 @@ def run_post_processing( If None, internal pareto front is used to determine best values. """ - F = self.optimization_problem.transform_maximization(F_minimized, scores='objectives') - population = self._create_population(X_transformed, F, F_minimized, G, CV) + F = self.optimization_problem.transform_maximization( + F_minimized, scores='objectives' + ) + population = self._create_population( + X_transformed, F, F_minimized, G, CV_nonlincon + ) self.results.update(population) pareto_front = self._create_pareto_front(X_opt_transformed) @@ -576,7 +620,8 @@ def run_post_processing( if meta_front is not None: self.results.update_meta(meta_front) - if current_generation % self.progress_frequency == 0: + if self.progress_frequency is not None \ + and current_generation % self.progress_frequency == 0: self.results.plot_figures(show=False) self._evaluate_callbacks(current_generation) @@ -587,7 +632,7 @@ def run_post_processing( for x in population.x: x_key = x.tobytes() if x not in self.results.meta_front.x: - self.optimization_problem.prune_cache(x_key) + self.optimization_problem.prune_cache(x_key, close=False) else: self._current_cache_entries.append(x_key) @@ -595,7 +640,7 @@ def run_post_processing( for x_key in self._current_cache_entries: x = np.frombuffer(x_key) if not np.all(np.isin(x, self.results.meta_front.x)): - self.optimization_problem.prune_cache(x_key) + self.optimization_problem.prune_cache(x_key, close=False) self._current_cache_entries.remove(x_key) self._log_results(current_generation) diff --git a/CADETProcess/optimization/population.py b/CADETProcess/optimization/population.py index 598cb066..9dc02c96 100644 --- a/CADETProcess/optimization/population.py +++ b/CADETProcess/optimization/population.py @@ -36,15 +36,16 @@ def __init__(self, id=None): Identifier for the population. If None, a random UUID will be generated. """ self._individuals = {} + if id is None: self.id = uuid.uuid4() else: if isinstance(id, bytes): - id = id.decode(encoding='utf=8') + id = id.decode(encoding="utf=8") self.id = uuid.UUID(id) @property - def feasible(self): + def feasible(self) -> "Population": """Population: Population containing only feasible individuals.""" pop = Population() pop._individuals = {ind.id: ind for ind in self.individuals if ind.is_feasible} @@ -52,7 +53,7 @@ def feasible(self): return pop @property - def infeasible(self): + def infeasible(self) -> "Population": """Population: Population containing only infeasible individuals.""" pop = Population() pop._individuals = { @@ -62,27 +63,27 @@ def infeasible(self): return pop @property - def n_x(self): + def n_x(self) -> int: """int: Number of optimization variables.""" return self.individuals[0].n_x @property - def n_f(self): + def n_f(self) -> int: """int: Number of objective metrics.""" return self.individuals[0].n_f @property - def n_g(self): + def n_g(self) -> int: """int: Number of nonlinear constraint metrics.""" return self.individuals[0].n_g @property - def n_m(self): + def n_m(self) -> int: """int: Number of meta scores.""" return self.individuals[0].n_m @property - def dimensions(self): + def dimensions(self) -> tuple[int]: """tuple: Individual dimensions (n_x, n_f, n_g, n_m)""" if self.n_individuals == 0: return None @@ -90,42 +91,48 @@ def dimensions(self): return self.individuals[0].dimensions @property - def objectives_minimization_factors(self): + def objectives_minimization_factors(self) -> np.ndarray: + """np.ndarray: Array indicating objectives transformed to minimization.""" return self.individuals[0].objectives_minimization_factors @property - def meta_scores_minimization_factors(self): + def meta_scores_minimization_factors(self) -> np.ndarray: + """np.ndarray: Array indicating meta sorces transformed to minimization.""" return self.individuals[0].meta_scores_minimization_factors @property - def variable_names(self): + def variable_names(self) -> list[str]: """list: Names of the optimization variables.""" if self.individuals[0].variable_names is None: - return [f'x_{i}' for i in range(self.n_x)] + return [f"x_{i}" for i in range(self.n_x)] else: return self.individuals[0].variable_names @property - def independent_variable_names(self): + def independent_variable_names(self) -> list[str]: """list: Names of the independent variables.""" return self.individuals[0].independent_variable_names @property - def objective_labels(self): + def objective_labels(self) -> list[str]: """list: Labels of the objective metrics.""" return self.individuals[0].objective_labels @property - def contraint_labels(self): + def nonlinear_constraint_labels(self) -> list[str]: """list: Labels of the nonlinear constraint metrics.""" - return self.individuals[0].contraint_labels + return self.individuals[0].nonlinear_constraint_labels @property - def meta_score_labels(self): + def meta_score_labels(self) -> list[str]: """list: Labels of the meta scores.""" return self.individuals[0].meta_score_labels - def add_individual(self, individual, ignore_duplicate=True): + def add_individual( + self, + individual: Individual, + ignore_duplicate: bool | None = True, + ): """Add individual to population. Parameters @@ -147,8 +154,7 @@ def add_individual(self, individual, ignore_duplicate=True): if not isinstance(individual, Individual): raise TypeError("Expected Individual") - if self.dimensions is not None \ - and individual.dimensions != self.dimensions: + if self.dimensions is not None and individual.dimensions != self.dimensions: raise CADETProcessError("Individual does not match dimensions.") if individual in self: @@ -225,149 +231,164 @@ def remove_similar(self): pass @property - def individuals(self): + def individuals(self) -> list[Individual]: """list: All individuals.""" return list(self._individuals.values()) @property - def n_individuals(self): + def n_individuals(self) -> int: """int: Number of indivuals.""" return len(self.individuals) @property - def x(self): + def x(self) -> np.ndarray: """np.array: All evaluated points.""" return np.array([ind.x for ind in self.individuals]) @property - def x_transformed(self): + def x_transformed(self) -> np.ndarray: """np.array: All evaluated points in independent transformed space.""" return np.array([ind.x_transformed for ind in self.individuals]) @property - def f(self): + def cv_bounds(self) -> np.ndarray: + """np.array: All evaluated bound constraint violations.""" + return np.array([ind.cv_bounds for ind in self.individuals]) + + @property + def cv_lincon(self) -> np.ndarray: + """np.array: All evaluated linear constraint violations.""" + return np.array([ind.cv_lincon for ind in self.individuals]) + + @property + def cv_lineqcon(self) -> np.ndarray: + """np.array: All evaluated linear equality constraint violations.""" + return np.array([ind.cv_lineqcon for ind in self.individuals]) + + @property + def f(self) -> np.ndarray: """np.array: All evaluated objective function values.""" return np.array([ind.f for ind in self.individuals]) @property - def f_minimized(self): + def f_minimized(self) -> np.ndarray: """np.array: All evaluated objective function values, transformed to be minimized.""" return np.array([ind.f_min for ind in self.individuals]) @property - def f_best(self): + def f_best(self) -> np.ndarray: """np.array: Best objective values.""" f_best = np.min(self.f_minimized, axis=0) return np.multiply(self.objectives_minimization_factors, f_best) @property - def f_min(self): + def f_min(self) -> np.ndarray: """np.array: Minimum objective values.""" return np.min(self.f, axis=0) @property - def f_max(self): + def f_max(self) -> np.ndarray: """np.array: Maximum objective values.""" return np.max(self.f, axis=0) @property - def f_avg(self): + def f_avg(self) -> np.ndarray: """np.array: Average objective values.""" return np.mean(self.f, axis=0) @property - def g(self): + def g(self) -> np.ndarray: """np.array: All evaluated nonlinear constraint function values.""" if self.dimensions[2] > 0: return np.array([ind.g for ind in self.individuals]) @property - def g_best(self): + def g_best(self) -> np.ndarray: """np.array: Best nonlinear constraint values.""" - indices = np.argmin(self.cv, axis=0) + indices = np.argmin(self.cv_nonlincon, axis=0) return [self.g[ind, i] for i, ind in enumerate(indices)] @property - def g_min(self): + def g_min(self) -> np.ndarray: """np.array: Minimum nonlinear constraint values.""" if self.dimensions[2] > 0: return np.min(self.g, axis=0) @property - def g_max(self): + def g_max(self) -> np.ndarray: """np.array: Maximum nonlinear constraint values.""" if self.dimensions[2] > 0: return np.max(self.g, axis=0) @property - def g_avg(self): + def g_avg(self) -> np.ndarray: """np.array: Average nonlinear constraint values.""" if self.dimensions[2] > 0: return np.mean(self.g, axis=0) @property - def cv(self): - """np.array: All evaluated nonlinear constraint function values.""" + def cv_nonlincon(self) -> np.ndarray: + """np.array: All evaluated nonlinear constraint violation values.""" if self.dimensions[2] > 0: - return np.array([ind.cv for ind in self.individuals]) + return np.array([ind.cv_nonlincon for ind in self.individuals]) @property - def cv_min(self): + def cv_nonlincon_min(self) -> np.ndarray: """np.array: Minimum nonlinear constraint violation values.""" if self.dimensions[2] > 0: - return np.min(self.cv, axis=0) + return np.min(self.cv_nonlincon, axis=0) @property - def cv_max(self): + def cv_nonlincon_max(self) -> np.ndarray: """np.array: Maximum nonlinearconstraint violation values.""" if self.dimensions[2] > 0: - return np.max(self.cv, axis=0) + return np.max(self.cv_nonlincon, axis=0) @property - def cv_avg(self): + def cv_nonlincon_avg(self) -> np.ndarray: """np.array: Average nonlinear constraint violation values.""" if self.dimensions[2] > 0: - return np.mean(self.cv, axis=0) + return np.mean(self.cv_nonlincon, axis=0) @property - def m(self): + def m(self) -> np.ndarray: """np.array: All evaluated meta scores.""" if self.dimensions[3] > 0: return np.array([ind.m for ind in self.individuals]) @property - def m_minimized(self): + def m_minimized(self) -> np.ndarray: """np.array: All evaluated meta scores, transformed to be minimized.""" if self.dimensions[3] > 0: return np.array([ind.m_min for ind in self.individuals]) @property - def m_best(self): + def m_best(self) -> np.ndarray: """np.array: Best meta scores.""" if self.dimensions[3] > 0: m_best = np.min(self.m_minimized, axis=0) return np.multiply(self.meta_scores_minimization_factors, m_best) @property - def m_min(self): + def m_min(self) -> np.ndarray: """np.array: Minimum meta scores.""" if self.dimensions[3] > 0: return np.min(self.m, axis=0) @property - def m_max(self): + def m_max(self) -> np.ndarray: """np.array: Maximum meta scores.""" if self.dimensions[3] > 0: return np.max(self.m, axis=0) @property - def m_avg(self): + def m_avg(self) -> np.ndarray: """np.array: Average meta scores.""" if self.dimensions[3] > 0: return np.mean(self.m, axis=0) @property - def is_feasilbe(self): + def is_feasilbe(self) -> bool: """np.array: False if any constraint is not met. True otherwise.""" return np.array([ind.is_feasible for ind in self.individuals]) @@ -399,14 +420,14 @@ def setup_objectives_figure(self, include_meta=True, plot_individual=False): space_fig_all, space_axs_all = plt.subplots( nrows=m, ncols=n, - figsize=(n*8 + 2, m*8 + 2), + figsize=(n * 8 + 2, m * 8 + 2), squeeze=False, ) plt.close(space_fig_all) space_figs_ind = [] space_axs_ind = [] - for i in range(m*n): + for i in range(m * n): fig, ax = plt.subplots() space_figs_ind.append(fig) space_axs_ind.append(ax) @@ -468,7 +489,7 @@ def plot_objectives( figs = [figs] layout = plotting.Layout() - layout.y_label = '$f~/~-$' + layout.y_label = "$f~/~-$" variables = self.variable_names feasible = self.feasible @@ -507,7 +528,9 @@ def plot_objectives( if len(infeasible) > 0 and plot_infeasible: v_metric_infeas = values_infeas[:, i_metric] - ax.scatter(x_var_infeas, v_metric_infeas, alpha=0.5, color=color_infeas) + ax.scatter( + x_var_infeas, v_metric_infeas, alpha=0.5, color=color_infeas + ) points = np.vstack([col.get_offsets() for col in ax.collections]) @@ -518,21 +541,18 @@ def plot_objectives( layout.x_label = var if autoscale and np.min(x_all) > 0: if np.max(x_all) / np.min(x_all[x_all > 0]) > 100.0: - ax.set_xscale('log') + ax.set_xscale("log") layout.x_label = f"$log_{{10}}$({var})" y_min = np.nanmin(v_all) y_max = np.nanmax(v_all) - y_lim = ( - min(0.9*y_min, y_min - 0.01*(y_max-y_min)), - 1.1*y_max - ) + y_lim = (min(0.9 * y_min, y_min - 0.01 * (y_max - y_min)), 1.1 * y_max) layout.y_label = label if autoscale and np.min(v_all) > 0: if np.max(v_all) / np.min(v_all[v_all > 0]) > 100.0: - ax.set_yscale('log') + ax.set_yscale("log") layout.y_label = f"$log_{{10}}$({label})" - y_lim = (y_min/2, y_max*2) + y_lim = (y_min / 2, y_max * 2) if y_min != y_max: layout.y_lim = y_lim @@ -556,13 +576,9 @@ def plot_objectives( plot_directory = Path(plot_directory) if plot_individual: for i, fig in enumerate(figs): - fig.savefig( - f'{plot_directory / "objectives"}_{i}.png' - ) + fig.savefig(f'{plot_directory / "objectives"}_{i}.png') else: - figs[0].savefig( - f'{plot_directory / "objectives"}.png' - ) + figs[0].savefig(f'{plot_directory / "objectives"}.png') return figs, axs @@ -598,10 +614,11 @@ def plot_pareto( plot=None, include_meta=True, plot_infeasible=True, - color_feas='blue', - color_infeas='red', + color_feas="blue", + color_infeas="red", show=True, - plot_directory=None): + plot_directory=None, + ): """Plot pairwise Pareto fronts for each generation in the optimization. The Pareto front represents the optimal solutions that cannot be improved in one @@ -709,7 +726,7 @@ def plot_corner(self, use_transformed=False, show=True, plot_directory=None): use_math_text=True, quiet=True, ) - fig_size = 6*len(labels) + fig_size = 6 * len(labels) fig.set_size_inches((fig_size, fig_size)) fig.tight_layout() @@ -812,90 +829,30 @@ def from_dict(cls, data): Population The Population created from the data. """ - id = data['id'] + id = data["id"] if isinstance(id, bytes): - id = id.decode(encoding='utf=8') + id = id.decode(encoding="utf=8") population = cls(id) - for individual_data in data['individuals'].values(): + for individual_data in data["individuals"].values(): individual = Individual.from_dict(individual_data) population.add_individual(individual) return population class ParetoFront(Population): - def __init__(self, similarity_tol=1e-1, cv_tol=1e-6, *args, **kwargs): + def __init__(self, similarity_tol=1e-1, *args, **kwargs): self.similarity_tol = similarity_tol - self.cv_tol = cv_tol super().__init__(*args, **kwargs) - def update_individual(self, individual): - """Update the Pareto front with new individual. - - If any individual in the pareto front is dominated, it is removed. - - Parameters - ---------- - individual : Individual - Individual to update the pareto front with. - - Returns - ------- - significant_improvement : bool - True if pareto front has improved significantly. False otherwise. - """ - significant = [] - - is_dominated = False - dominates_one = False - to_remove = [] - - try: - if np.any(np.array(individual.g) > self.cv_tol): - return False - except TypeError: - pass - - for i, ind_pareto in enumerate(self): - if not dominates_one and ind_pareto.dominates(individual): - is_dominated = True - break - elif individual.dominates(ind_pareto): - dominates_one = True - to_remove.append(ind_pareto) - significant.append( - not individual.is_similar(ind_pareto, self.similarity_tol) - ) - - for i in reversed(to_remove): - self.remove_individual(i) - - if not is_dominated: - if len(self) == 0: - significant.append(True) - elif sum(self.dimensions[1:]) > 1: - if len(significant) == 0 \ - or (len(significant) and any(significant)): - significant.append(True) - - self.add_individual(individual) - - if len(self) == 0: - self.add_individual(individual) - - if self.similarity_tol != 0: - self.remove_similar() - - return any(significant) - - def update_population(self, population): + def update_population(self, population: Population): """Update the Pareto front with new population. If any individual in the pareto front is dominated, it is removed. Parameters ---------- - population : list - Individuals to update the pareto front with. + population : Population + Population to update the pareto front with. Returns ------- @@ -913,24 +870,29 @@ def update_population(self, population): has_twin = False to_remove = [] - try: - # Do not add if invalid - if np.any(np.array(ind_new.cv) > self.cv_tol): - continue - except TypeError: - pass + if not ind_new.is_feasible: + continue for i, ind_pareto in enumerate(self): # Do not add if is dominated if not dominates_one and ind_pareto.dominates(ind_new): is_dominated = True break + + # Remove existing if infeasible + elif not ind_pareto.is_feasible: + dominates_one = True + to_remove.append(ind_pareto) + significant.append(True) + + # Remove existing if new dominates elif ind_new.dominates(ind_pareto): - # Remove existing if new dominates dominates_one = True to_remove.append(ind_pareto) if not ind_new.is_similar(ind_pareto, self.similarity_tol): significant.append(True) + + # Ignore similar individuals elif ind_new.is_similar(ind_pareto, self.similarity_tol): has_twin = True break @@ -949,11 +911,30 @@ def update_population(self, population): if len(self) == 0: # Use least inveasible individuals. - indices = np.argmin(population.cv, axis=0) + indices = np.argmin(population.cv_bounds, axis=0) for index in indices: ind_new = population.individuals[index] self.add_individual(ind_new) + indices = np.argmin(population.cv_lincon, axis=0) + for index in indices: + ind_new = population.individuals[index] + self.add_individual(ind_new) + + indices = np.argmin(population.cv_lineqcon, axis=0) + for index in indices: + ind_new = population.individuals[index] + self.add_individual(ind_new) + + if self.n_g > 0: + indices = np.argmin(population.cv_nonlincon, axis=0) + for index in indices: + ind_new = population.individuals[index] + self.add_individual(ind_new) + + elif len(self) > 1: + self.remove_infeasible() + if self.similarity_tol is not None: self.remove_similar() @@ -962,11 +943,8 @@ def update_population(self, population): def remove_infeasible(self): """Remove infeasible individuals from pareto front.""" for ind in self.individuals.copy(): - try: - if np.any(np.array(ind.cv) > self.cv_tol): - self.remove_individual(ind) - except TypeError: - pass + if not ind.is_feasible: + self.remove_individual(ind) def remove_dominated(self): """Remove dominated individuals from pareto front.""" @@ -998,8 +976,7 @@ def to_dict(self): """ front = super().to_dict() if self.similarity_tol is not None: - front['similarity_tol'] = self.similarity_tol - front['cv_tol'] = self.cv_tol + front["similarity_tol"] = self.similarity_tol return front @@ -1017,8 +994,11 @@ def from_dict(cls, data): ParetoFront ParetoFront created from data. """ - front = cls(data['similarity_tol'], data['cv_tol'], data['id']) - for individual_data in data['individuals'].values(): + front = cls( + similarity_tol=data["similarity_tol"], + id=data["id"] + ) + for individual_data in data["individuals"].values(): individual = Individual.from_dict(individual_data) front.add_individual(individual) diff --git a/CADETProcess/optimization/pymooAdapter.py b/CADETProcess/optimization/pymooAdapter.py index d63543b4..5ef399df 100644 --- a/CADETProcess/optimization/pymooAdapter.py +++ b/CADETProcess/optimization/pymooAdapter.py @@ -40,10 +40,10 @@ class PymooInterface(OptimizerBase): n_max_gen = UnsignedInteger() n_skip = UnsignedInteger(default=0) - x_tol = xtol # Alias for uniform interface - f_tol = ftol # Alias for uniform interface - cv_tol = cvtol # Alias for uniform interface - n_max_iter = n_max_gen # Alias for uniform interface + x_tol = xtol # Alias for uniform interface + f_tol = ftol # Alias for uniform interface + cv_nonlincon_tol = cvtol # Alias for uniform interface + n_max_iter = n_max_gen # Alias for uniform interface _specific_options = [ 'seed', 'pop_size', 'xtol', 'ftol', 'cvtol', 'n_max_gen', 'n_skip' @@ -111,7 +111,7 @@ def run(self, optimization_problem: OptimizationProblem, x0=None): ref_dirs=ref_dirs, pop_size=pop_size, sampling=pop, - repair=RepairIndividuals(optimization_problem), + repair=RepairIndividuals(self, optimization_problem), ) n_max_gen = self.get_max_number_of_generations(optimization_problem) @@ -237,23 +237,26 @@ def __init__(self, optimization_problem, parallelization_backend, **kwargs): def _evaluate(self, X, out, *args, **kwargs): opt = self.optimization_problem if opt.n_objectives > 0: - F = opt.evaluate_objectives_population( + F = opt.evaluate_objectives( X, untransform=True, + get_dependent_values=True, ensure_minimization=True, parallelization_backend=self.parallelization_backend, ) out["F"] = np.array(F) if opt.n_nonlinear_constraints > 0: - G = opt.evaluate_nonlinear_constraints_population( + G = opt.evaluate_nonlinear_constraints( X, untransform=True, + get_dependent_values=True, parallelization_backend=self.parallelization_backend, ) - CV = opt.evaluate_nonlinear_constraints_violation_population( + CV = opt.evaluate_nonlinear_constraints_violation( X, untransform=True, + get_dependent_values=True, parallelization_backend=self.parallelization_backend, ) out["G"] = np.array(CV) @@ -263,7 +266,8 @@ def _evaluate(self, X, out, *args, **kwargs): class RepairIndividuals(Repair): - def __init__(self, optimization_problem, *args, **kwargs): + def __init__(self, optimizer, optimization_problem, *args, **kwargs): + self.optimizer = optimizer self.optimization_problem = optimization_problem super().__init__(*args, **kwargs) @@ -272,7 +276,14 @@ def _do(self, problem, X, **kwargs): X_new = None for i, ind in enumerate(X): if not self.optimization_problem.check_individual( - ind, untransform=True, get_dependent_values=True): + ind, + untransform=True, + get_dependent_values=True, + cv_bounds_tol=self.optimizer.cv_bounds_tol, + cv_lincon_tol=self.optimizer.cv_lincon_tol, + cv_lineqcon_tol=self.optimizer.cv_lineqcon_tol, + check_nonlinear_constraints=False, + ): if X_new is None: X_new = self.optimization_problem.create_initial_values( len(X), include_dependent_variables=False diff --git a/CADETProcess/optimization/results.py b/CADETProcess/optimization/results.py index d8657f93..53a998c4 100644 --- a/CADETProcess/optimization/results.py +++ b/CADETProcess/optimization/results.py @@ -1,5 +1,7 @@ import csv +import os from pathlib import Path +from typing import Literal import warnings from addict import Dict @@ -66,8 +68,11 @@ class OptimizationResults(Structure): system_information = Dictionary() def __init__( - self, optimization_problem, optimizer, - similarity_tol=0, cv_tol=1e-6): + self, + optimization_problem, + optimizer, + similarity_tol: float = 0, + ): self.optimization_problem = optimization_problem self.optimizer = optimizer @@ -76,7 +81,6 @@ def __init__( self._population_all = Population() self._populations = [] self._similarity_tol = similarity_tol - self._cv_tol = cv_tol self._pareto_fronts = [] if optimization_problem.n_multi_criteria_decision_functions > 0: @@ -89,11 +93,11 @@ def __init__( self.system_information = system_information @property - def results_directory(self): + def results_directory(self) -> Path: return self._results_directory @results_directory.setter - def results_directory(self, results_directory): + def results_directory(self, results_directory: str | os.PathLike): if results_directory is not None: results_directory = Path(results_directory) self.plot_directory = Path(results_directory / 'figures') @@ -104,7 +108,7 @@ def results_directory(self, results_directory): self._results_directory = results_directory @property - def is_finished(self): + def is_finished(self) -> bool: if self.exit_flag is None: return False else: @@ -115,40 +119,40 @@ def optimizer_state(self): return self._optimizer_state @property - def populations(self): + def populations(self) -> list[Population]: return self._populations @property - def population_last(self): + def population_last(self) -> Population: return self.populations[-1] @property - def population_all(self): + def population_all(self) -> Population: return self._population_all @property - def pareto_fronts(self): + def pareto_fronts(self) -> list[ParetoFront]: return self._pareto_fronts @property - def pareto_front(self): + def pareto_front(self) -> ParetoFront: return self._pareto_fronts[-1] @property - def meta_fronts(self): + def meta_fronts(self) -> list[Population]: if self._meta_fronts is None: return self.pareto_fronts else: return self._meta_fronts @property - def meta_front(self): + def meta_front(self) -> Population: if self._meta_fronts is None: return self.pareto_front else: return self._meta_fronts[-1] - def update(self, new): + def update(self, new: Individual | Population): """Update Results. Parameters @@ -171,7 +175,7 @@ def update(self, new): self._populations.append(population) self.population_all.update(population) - def update_pareto(self, pareto_new=None): + def update_pareto(self, pareto_new: Population | None = None): """Update pareto front with new population. Parameters @@ -180,7 +184,7 @@ def update_pareto(self, pareto_new=None): New pareto front. If None, update existing front with latest population. """ pareto_front = ParetoFront( - similarity_tol=self._similarity_tol, cv_tol=self._cv_tol + similarity_tol=self._similarity_tol ) if pareto_new is not None: @@ -194,7 +198,7 @@ def update_pareto(self, pareto_new=None): pareto_front.remove_similar() self._pareto_fronts.append(pareto_front) - def update_meta(self, meta_front): + def update_meta(self, meta_front: Population): """Update meta front with new population. Parameters @@ -207,78 +211,93 @@ def update_meta(self, meta_front): self._meta_fronts.append(meta_front) @property - def n_evals(self): + def n_evals(self) -> int: """int: Number of evaluations.""" return sum([len(pop) for pop in self.populations]) @property - def n_gen(self): + def n_gen(self) -> int: """int: Number of generations.""" return len(self.populations) @property - def x(self): - """np.array: Optimal points.""" + def x(self) -> np.ndarray: + """np.array: Optimal points in untransformed space.""" return self.meta_front.x @property - def x_transformed(self): - """np.array: Optimal points.""" + def x_transformed(self) -> np.ndarray: + """np.array: Optimal points in transformed space.""" return self.meta_front.x_transformed @property - def f(self): - """np.array: Optimal objective values.""" + def cv_bounds(self) -> np.ndarray: + """np.array: Bound constraint violation of optimal points.""" + return self.meta_front.cv_bounds + + @property + def cv_lincon(self) -> np.ndarray: + """np.array: Linear constraint violation of optimal points.""" + return self.meta_front.cv_lincon + + @property + def cv_lineqcon(self) -> np.ndarray: + """np.array: Linear equality constraint violation of optimal points.""" + return self.meta_front.cv_lineqcon + + @property + def f(self) -> np.ndarray: + """np.array: Objective function values of optimal points.""" return self.meta_front.f @property - def g(self): - """np.array: Optimal nonlinear constraint values.""" + def g(self) -> np.ndarray: + """np.array: Nonlinear constraint function values of optimal points.""" return self.meta_front.g @property - def cv(self): - """np.array: Optimal nonlinear constraint violations.""" - return self.meta_front.cv + def cv_nonlincon(self) -> np.ndarray: + """np.array: Nonlinear constraint violation values of optimal points.""" + return self.meta_front.cv_nonlincon @property - def m(self): - """np.array: Optimal meta score values.""" + def m(self) -> np.ndarray: + """np.array: Meta scores of optimal points.""" return self.meta_front.m @property - def n_evals_history(self): + def n_evals_history(self) -> np.ndarray: """int: Number of evaluations per generation.""" n_evals = [len(pop) for pop in self.populations] return np.cumsum(n_evals) @property - def f_best_history(self): + def f_best_history(self) -> np.ndarray: """np.array: Best objective values per generation.""" return np.array([pop.f_best for pop in self.meta_fronts]) @property - def f_min_history(self): + def f_min_history(self) -> np.ndarray: """np.array: Minimum objective values per generation.""" return np.array([pop.f_min for pop in self.meta_fronts]) @property - def f_max_history(self): + def f_max_history(self) -> np.ndarray: """np.array: Maximum objective values per generation.""" return np.array([pop.f_max for pop in self.meta_fronts]) @property - def f_avg_history(self): + def f_avg_history(self) -> np.ndarray: """np.array: Average objective values per generation.""" return np.array([pop.f_avg for pop in self.meta_fronts]) @property - def g_best_history(self): + def g_best_history(self) -> np.ndarray: """np.array: Best nonlinear constraint per generation.""" return np.array([pop.g_best for pop in self.meta_fronts]) @property - def g_min_history(self): + def g_min_history(self) -> np.ndarray: """np.array: Minimum nonlinear constraint values per generation.""" if self.optimization_problem.n_nonlinear_constraints == 0: return None @@ -286,7 +305,7 @@ def g_min_history(self): return np.array([pop.g_min for pop in self.meta_fronts]) @property - def g_max_history(self): + def g_max_history(self) -> np.ndarray: """np.array: Maximum nonlinear constraint values per generation.""" if self.optimization_problem.n_nonlinear_constraints == 0: return None @@ -294,7 +313,7 @@ def g_max_history(self): return np.array([pop.g_max for pop in self.meta_fronts]) @property - def g_avg_history(self): + def g_avg_history(self) -> np.ndarray: """np.array: Average nonlinear constraint values per generation.""" if self.optimization_problem.n_nonlinear_constraints == 0: return None @@ -302,36 +321,36 @@ def g_avg_history(self): return np.array([pop.g_avg for pop in self.meta_fronts]) @property - def cv_min_history(self): + def cv_nonlincon_min_history(self) -> np.ndarray: """np.array: Minimum nonlinear constraint violation values per generation.""" if self.optimization_problem.n_nonlinear_constraints == 0: return None else: - return np.array([pop.cv_min for pop in self.meta_fronts]) + return np.array([pop.cv_nonlincon_min for pop in self.meta_fronts]) @property - def cv_max_history(self): + def cv_nonlincon_max_history(self) -> np.ndarray: """np.array: Maximum nonlinear constraint violation values per generation.""" if self.optimization_problem.n_nonlinear_constraints == 0: return None else: - return np.array([pop.cv_max for pop in self.meta_fronts]) + return np.array([pop.cv_nonlincon_max for pop in self.meta_fronts]) @property - def cv_avg_history(self): + def cv_nonlincon_avg_history(self) -> np.ndarray: """np.array: Average nonlinear constraint violation values per generation.""" if self.optimization_problem.n_nonlinear_constraints == 0: return None else: - return np.array([pop.cv_avg for pop in self.meta_fronts]) + return np.array([pop.cv_nonlincon_avg for pop in self.meta_fronts]) @property - def m_best_history(self): + def m_best_history(self) -> np.ndarray: """np.array: Best meta scores per generation.""" return np.array([pop.m_best for pop in self.meta_fronts]) @property - def m_min_history(self): + def m_min_history(self) -> np.ndarray: """np.array: Minimum meta scores per generation.""" if self.optimization_problem.n_meta_scores == 0: return None @@ -339,7 +358,7 @@ def m_min_history(self): return np.array([pop.m_min for pop in self.meta_fronts]) @property - def m_max_history(self): + def m_max_history(self) -> np.ndarray: """np.array: Maximum meta scores per generation.""" if self.optimization_problem.n_meta_scores == 0: return None @@ -347,7 +366,7 @@ def m_max_history(self): return np.array([pop.m_max for pop in self.meta_fronts]) @property - def m_avg_history(self): + def m_avg_history(self) -> np.ndarray: """np.array: Average meta scores per generation.""" if self.optimization_problem.n_meta_scores == 0: return None @@ -766,7 +785,15 @@ def plot_convergence( f'{plot_directory / figname}.png' ) - def save_results(self, name): + def save_results(self, file_name: str): + """ + Save results to H5 file. + + Parameters + ---------- + file_name : str + Results file name without file extension. + """ if self.results_directory is not None: self._update_csv(self.population_last, 'results_all', mode='a') self._update_csv(self.population_last, 'results_last', mode='w') @@ -776,10 +803,10 @@ def save_results(self, name): results = H5() results.root = Dict(self.to_dict()) - results.filename = self.results_directory / f'{name}.h5' + results.filename = self.results_directory / f'{file_name}.h5' results.save() - def to_dict(self): + def to_dict(self) -> dict: """Convert Results to a dictionary. Returns @@ -805,7 +832,7 @@ def to_dict(self): return data - def update_from_dict(self, data): + def update_from_dict(self, data: dict): """Update internal state from dictionary. Parameters @@ -836,13 +863,14 @@ def setup_csv(self): if self.optimization_problem.n_meta_scores > 0: self._setup_csv('results_meta') - def _setup_csv(self, file_name): - """Create csv file for optimization results. + def _setup_csv(self, file_name: str): + """ + Create csv file for optimization results. Parameters ---------- - file_name : {str, Path} - Path to save results. + file_name : str + Results file name without file extension. """ header = [ "id", @@ -862,15 +890,21 @@ def _setup_csv(self, file_name): writer = csv.writer(csvfile, delimiter=",") writer.writerow(header) - def _update_csv(self, population, file_name, mode='a'): - """Update csv file with latest population. + def _update_csv( + self, + population: Population, + file_name: str, + mode: Literal["w", "b"], + ): + """ + Update csv file with latest population. Parameters ---------- population : Population latest Population. - file_name : {str, Path} - Path to save results. + file_name : str + Results file name without file extension. mode : {'a', 'w'} a: append to existing file. w: Create new csv. diff --git a/CADETProcess/optimization/scipyAdapter.py b/CADETProcess/optimization/scipyAdapter.py index 36cec449..271c73e3 100644 --- a/CADETProcess/optimization/scipyAdapter.py +++ b/CADETProcess/optimization/scipyAdapter.py @@ -79,7 +79,7 @@ def run(self, optimization_problem: OptimizationProblem, x0=None): def objective_function(x): return optimization_problem.evaluate_objectives( - x, untransform=True, ensure_minimization=True, + x, untransform=True, get_dependent_values=True, ensure_minimization=True, )[0] def callback_function(x, state=None): @@ -99,13 +99,14 @@ def callback_function(x, state=None): f = optimization_problem.evaluate_objectives( x, untransform=True, + get_dependent_values=True, ensure_minimization=True, ) g = optimization_problem.evaluate_nonlinear_constraints( - x, untransform=True + x, untransform=True, get_dependent_values=True, ) cv = optimization_problem.evaluate_nonlinear_constraints_violation( - x, untransform=True + x, untransform=True, get_dependent_values=True, ) self.run_post_processing(x, f, g, cv, self.n_evals) @@ -230,8 +231,8 @@ def get_lineqcon_obj(self, optimization_problem): if optimization_problem.n_linear_equality_constraints == 0: return None - lb = optimization_problem.beq_transformed - optimization_problem.eps_eq - ub = optimization_problem.beq_transformed + optimization_problem.eps_eq + lb = optimization_problem.beq_transformed - optimization_problem.eps_lineq + ub = optimization_problem.beq_transformed + optimization_problem.eps_lineq return optimize.LinearConstraint( optimization_problem.Aeq_independent_transformed, lb, ub, @@ -275,7 +276,9 @@ def makeConstraint(i): in the main loop. """ constr = optimize.NonlinearConstraint( - lambda x: opt.evaluate_nonlinear_constraints_violation(x, untransform=True)[i], + lambda x: opt.evaluate_nonlinear_constraints_violation( + x, untransform=True, get_dependent_values=True, + )[i], lb=-np.inf, ub=0, finite_diff_rel_step=self.finite_diff_rel_step, keep_feasible=True @@ -395,7 +398,7 @@ class TrustConstr(SciPyInterface): disp = Bool(default=False) x_tol = xtol # Alias for uniform interface - cv_tol = gtol # Alias for uniform interface + cv_nonlincon_tol = gtol # Alias for uniform interface n_max_evals = maxiter # Alias for uniform interface n_max_iter = maxiter # Alias for uniform interface @@ -446,10 +449,10 @@ class COBYLA(SciPyInterface): disp = Bool(default=False) catol = UnsignedFloat(default=0.0002) - f_tol = tol # Alias for uniform interface - cv_tol = catol # Alias for uniform interface - n_max_evals = maxiter # Alias for uniform interface - n_max_iter = maxiter # Alias for uniform interface + x_tol = tol # Alias for uniform interface + cv_nonlincon_tol = catol # Alias for uniform interface + n_max_evals = maxiter # Alias for uniform interface + n_max_iter = maxiter # Alias for uniform interface _specific_options = ['rhobeg', 'tol', 'maxiter', 'disp', 'catol'] @@ -457,6 +460,9 @@ class COBYLA(SciPyInterface): class NelderMead(SciPyInterface): """Wrapper for the Nelder-Mead optimization method from the scipy optimization suite. + Supports: + - Bounds. + It defines the solver options in the 'options' variable as a dictionary. Parameters @@ -477,6 +483,7 @@ class NelderMead(SciPyInterface): disp : Bool, optional Set to True to print convergence messages. """ + supports_bounds = True maxiter = UnsignedInteger(default=1000) initial_simplex = None diff --git a/CADETProcess/processModel/binding.py b/CADETProcess/processModel/binding.py index 1f7c6a98..21b39fc3 100644 --- a/CADETProcess/processModel/binding.py +++ b/CADETProcess/processModel/binding.py @@ -39,6 +39,7 @@ 'GeneralizedIonExchange', 'HICConstantWaterActivity', 'HICWaterOnHydrophobicSurfaces', + 'MultiComponentColloidal', ] @@ -442,7 +443,7 @@ class AntiLangmuir(BindingBaseClass): Desorption rate constants. Length depends on `n_comp`. capacity : list of unsigned floats. Maximum adsorption capacities. Length depends on `n_comp`. - antilangmuir : list of unsigned floats, optional. + antilangmuir : list of {-1, 1}. Anti-Langmuir coefficients. Length depends on `n_comp`. """ @@ -450,7 +451,7 @@ class AntiLangmuir(BindingBaseClass): adsorption_rate = SizedUnsignedList(size='n_comp') desorption_rate = SizedUnsignedList(size='n_comp') capacity = SizedUnsignedList(size='n_comp') - antilangmuir = SizedUnsignedList(size='n_comp') + antilangmuir = SizedList(size='n_comp') _parameters = [ 'adsorption_rate', @@ -517,6 +518,8 @@ class MobilePhaseModulator(BindingBaseClass): hydrophobicity : list of unsigned floats. Parameters describing the hydrophobicity (HIC). Length depends on `n_comp`. + linear_threshold : UnsignedFloat + Concentration of c0 at which to switch to linear model approximation. """ adsorption_rate = SizedUnsignedList(size='n_comp') @@ -526,6 +529,7 @@ class MobilePhaseModulator(BindingBaseClass): beta = ion_exchange_characteristic hydrophobicity = SizedUnsignedList(size='n_comp') gamma = hydrophobicity + linear_threshold = UnsignedFloat(default=1e-8) _parameters = [ 'adsorption_rate', @@ -533,6 +537,7 @@ class MobilePhaseModulator(BindingBaseClass): 'capacity', 'ion_exchange_characteristic', 'hydrophobicity', + 'linear_threshold', ] @@ -1103,3 +1108,96 @@ class HICWaterOnHydrophobicSurfaces(BindingBaseClass): 'beta_0', 'beta_1', ] + + +class MultiComponentColloidal(BindingBaseClass): + """Colloidal isotherm from Xu and Lenhoff 2009. + + Attributes + ---------- + phase_ratio : unsigned float. + Phase ratio. + kappa_exponential : unsigned float. + Screening term exponential factor. + kappa_factor : unsigned float. + Screening term factor. + kappa_constant : unsigned float. + Screening term constant. + coordination_number : unsigned integer. + Coordination number. + logkeq_ph_exponent : list of unsigned floats. + Equilibrium constant factor exponent term for pH. Size depends on `n_comp`. + logkeq_power_exponent : list of unsigned floats. + Equilibrium constant power exponent term for salt. Size depends on `n_comp`. + logkeq_power_factor : list of unsigned floats. + Equilibrium constant power factor term for salt. Size depends on `n_comp`. + logkeq_exponent_factor : list of unsigned floats. + Equilibrium constant exponent factor term for salt. Size depends on `n_comp`. + logkeq_exponent_multiplier : list of unsigned floats. + Equilibrium constant exponent multiplier term for salt. Size depends on `n_comp`. + bpp_ph_exponent : list of unsigned floats.. + BPP constant exponent factor term for pH. Size depends on `n_comp`. + bpp_power_exponent : list of unsigned floats. + Bpp constant power exponent term for salt. Size depends on `n_comp`. + bpp_power_factor : list of unsigned floats. + Bpp constant power factor term for salt. Size depends on `n_comp`. + bpp_exponent_factor : list of unsigned floats. + Bpp constant exponent factor term for salt. Size depends on `n_comp`. + bpp_exponent_multiplier : list of unsigned floats. + Bpp constant exponent multiplier term for salt. Size depends on `n_comp`. + protein_radius : list of unsigned floats. + Protein radius. Size depends on `n_comp`. + kinetic_rate_constant : list of unsigned floats. + Adsorption kinetics. Size depends on `n_comp`. + linear_threshold : unsigned float. + Linear threshold. + use_ph : Boolean. + Include pH or not. + + """ + + bound_states = SizedUnsignedIntegerList( + size=('n_binding_sites', 'n_comp'), default=1 + ) + + phase_ratio = UnsignedFloat() + kappa_exponential = UnsignedFloat() + kappa_factor = UnsignedFloat() + kappa_constant = UnsignedFloat() + coordination_number = UnsignedInteger() + logkeq_ph_exponent = SizedList(size='n_comp') + logkeq_power_exponent = SizedList(size='n_comp') + logkeq_power_factor = SizedList(size='n_comp') + logkeq_exponent_factor = SizedList(size='n_comp') + logkeq_exponent_multiplier = SizedList(size='n_comp') + bpp_ph_exponent = SizedList(size='n_comp') + bpp_power_exponent = SizedList(size='n_comp') + bpp_power_factor = SizedList(size='n_comp') + bpp_exponent_factor = SizedList(size='n_comp') + bpp_exponent_multiplier = SizedList(size='n_comp') + protein_radius = SizedList(size='n_comp') + kinetic_rate_constant = SizedList(size='n_comp') + linear_threshold = UnsignedFloat(default=1e-8) + use_ph = Bool(default=False) + + _parameters = [ + 'phase_ratio', + 'kappa_exponential', + 'kappa_factor', + 'kappa_constant', + 'coordination_number', + 'logkeq_ph_exponent', + 'logkeq_power_exponent', + 'logkeq_power_factor', + 'logkeq_exponent_factor', + 'logkeq_exponent_multiplier', + 'bpp_ph_exponent', + 'bpp_power_exponent', + 'bpp_power_factor', + 'bpp_exponent_factor', + 'bpp_exponent_multiplier', + 'protein_radius', + 'kinetic_rate_constant', + 'linear_threshold', + 'use_ph', + ] diff --git a/CADETProcess/processModel/componentSystem.py b/CADETProcess/processModel/componentSystem.py index a4863b8d..e0ab9f99 100644 --- a/CADETProcess/processModel/componentSystem.py +++ b/CADETProcess/processModel/componentSystem.py @@ -1,5 +1,6 @@ from collections import defaultdict from functools import wraps +from typing import NoReturn from addict import Dict @@ -7,12 +8,11 @@ from CADETProcess.dataStructure import Structure from CADETProcess.dataStructure import String, Integer, UnsignedFloat - __all__ = ['ComponentSystem', 'Component', 'Species'] - class Species(Structure): - """Species class. + """ + Species class. Represent a species in a chemical system. @@ -24,32 +24,38 @@ class Species(Structure): The charge of the species. Default is 0. molecular_weight : float The molecular weight of the species. + density : float + Density of the species. """ - name = String() - charge = Integer(default=0) - molecular_weight = UnsignedFloat() + name: String = String() + charge: Integer = Integer(default=0) + molecular_weight: UnsignedFloat = UnsignedFloat() + density: UnsignedFloat = UnsignedFloat() class Component(Structure): - """Information about single component. + """ + Information about single component. A component can contain subspecies (e.g. differently charged variants). Attributes ---------- - name : String + name : str | None Name of the component. - species : list + species : list[Species] List of Subspecies. n_species : int Number of Subspecies. - label : list + label : list[str] Name of component (including species). - charge : list + charge : int | list[int | None] Charge of component (including species). - molecular_weight : list + molecular_weight : float | list[float | None] Molecular weight of component (including species). + density : float | list[float | None] + Density of component (including species). See Also -------- @@ -57,46 +63,61 @@ class Component(Structure): ComponentSystem """ - name = String() + name: String = String() def __init__( - self, name=None, species=None, charge=None, molecular_weight=None): + self, + name: str | None = None, + species: str | list[str | None] = None, + charge: int | list[int | None] = None, + molecular_weight: float | list[float | None] = None, + density: float | list[float | None] = None + ) -> NoReturn: """ Parameters ---------- - name : str, optional + name : str | None Name of the component. - species : str or list of str, optional + species : str | list[str | None] Names of the subspecies. - charge : int or list of int or None, optional + charge : int | list [int | None] Charges of the subspecies. - molecular_weight : float or list of float or None, optional + molecular_weight : float | list[float | None] Molecular weights of the subspecies. + density : float | list[float | None] + Density of component (including species). """ - self.name = name - self._species = [] + self.name: str | None = name + self._species: list[Species] = [] if species is None: - self.add_species(name, charge, molecular_weight) + self.add_species(name, charge, molecular_weight, density) elif isinstance(species, str): - self.add_species(species, charge, molecular_weight) + self.add_species(species, charge, molecular_weight, density) elif isinstance(species, list): if charge is None: charge = len(species) * [None] if molecular_weight is None: molecular_weight = len(species) * [None] + if density is None: + density = len(species) * [None] for i, spec in enumerate(species): - self.add_species(spec, charge[i], molecular_weight[i]) + self.add_species(spec, charge[i], molecular_weight[i], density[i]) else: raise CADETProcessError("Could not determine number of species") @property - def species(self): - """list: The subspecies of the component.""" + def species(self) -> list[Species]: + """list[Species]: The subspecies of the component.""" return self._species @wraps(Species.__init__) - def add_species(self, species, *args, **kwargs): + def add_species( + self, + species: str| Species , + *args, + **kwargs + ) -> Species: """ Add a subspecies to the component. @@ -115,29 +136,35 @@ def add_species(self, species, *args, **kwargs): if not isinstance(species, Species): species = Species(species, *args, **kwargs) self._species.append(species) + return species @property - def n_species(self): + def n_species(self) -> int: """int: The number of subspecies in the component.""" return len(self.species) @property - def label(self): - """list of str: The names of the subspecies.""" + def label(self) -> list[str]: + """list[str]: The names of the subspecies.""" return [spec.name for spec in self.species] @property - def charge(self): - """list of int or None: The charges of the subspecies.""" + def charge(self) -> list[int | None]: + """list[int | None]: The charges of the subspecies.""" return [spec.charge for spec in self.species] @property - def molecular_weight(self): - """list of float or None: The molecular weights of the subspecies.""" - return [spec.molecular_weight for spec in self.molecular_weight] + def molecular_weight(self) -> list[float | None]: + """list[float | None]: The molecular weights of the subspecies.""" + return [spec.molecular_weight for spec in self.species] + + @property + def density(self) -> list[float | None]: + """list[float | None]: The density of the subspecies.""" + return [spec.density for spec in self.species] - def __str__(self): - """String representation of the component.""" + def __str__(self) -> str: + """str: String representation of the component.""" return self.name def __iter__(self): @@ -146,7 +173,8 @@ def __iter__(self): class ComponentSystem(Structure): - """Information about components in system. + """ + Information about components in system. A component can contain subspecies (e.g. differently charged variants). @@ -154,7 +182,7 @@ class ComponentSystem(Structure): ---------- name : String Name of the component system. - components : list + components : list[Component] List of individual components. n_species : int Number of Subspecies. @@ -162,16 +190,18 @@ class ComponentSystem(Structure): Number of all component species. n_components : int Number of components. - indices : dict + indices : dict[str, list[int]] Component indices. - names : list + names : list[str] Names of all components. - species : list + species : list[str] Names of all component species. - charge : list + charges : list[int | None] Charges of all components species. - molecular_weight : list + molecular_weights : list[float | None] Molecular weights of all component species. + densities : list[float | None] + Densities of all component species. See Also -------- @@ -179,23 +209,32 @@ class ComponentSystem(Structure): Component """ - name = String() + name: String = String() def __init__( - self, components=None, name=None, charges=None, molecular_weights=None): - """Initialize the ComponentSystem object. + self, + components: int | list[str | Component | None] = None, + name: str | None = None, + charges: list[int | None] = None, + molecular_weights: list[float | None] = None, + densities: list[float | None] = None + ) -> None: + """ + Initialize the ComponentSystem object. Parameters ---------- - components : int, list, None + components : int | list[str | Component | None] The number of components or the list of components to be added. If None, no components are added. - name : str, None + name : str | None The name of the ComponentSystem. - charges : list, None + charges : list[int | None] The charges of each component. - molecular_weights : list, None + molecular_weights : list[float | None] The molecular weights of each component. + densities : list[float | None] + The densities of each component. Raises ------ @@ -203,9 +242,9 @@ def __init__( If the `components` argument is neither an int nor a list. """ - self.name = name - self._components = [] + self.name: str | None = name + self._components: list[Component] = [] if components is None: return @@ -222,51 +261,59 @@ def __init__( charges = n_comp * [None] if molecular_weights is None: molecular_weights = n_comp * [None] + if densities is None: + densities = n_comp * [None] for i, comp in enumerate(components): self.add_component( comp, charge=charges[i], molecular_weight=molecular_weights[i], + density=densities[i] ) @property - def components(self): - """list: List of components in the system.""" + def components(self) -> list[Component]: + """list[Component]: List of components in the system.""" return self._components @property - def components_dict(self): - """dict: Components indexed by name.""" + def components_dict(self) -> dict[str, Component]: + """dict[str, Component]: Components indexed by name.""" return { name: comp for name, comp in zip(self.names, self.components) } @property - def n_components(self): + def n_components(self) -> int: """int: Number of components.""" return len(self.components) @property - def n_comp(self): + def n_comp(self) -> int: """int: Number of species.""" return self.n_species @property - def n_species(self): + def n_species(self) -> int: """int: Number of species.""" return sum([comp.n_species for comp in self.components]) @wraps(Component.__init__) - def add_component(self, component, *args, **kwargs): + def add_component( + self, + component: str | Component, + *args: list, + **kwargs: dict + ) -> NoReturn: """ Add a component to the system. Parameters ---------- - component : {str, Component} - The class of the component to be added. + component : str | Component + The component instance or name of the component to be added. *args : list The positional arguments to be passed to the component class's constructor. **kwargs : dict @@ -284,12 +331,13 @@ def add_component(self, component, *args, **kwargs): self._components.append(component) - def remove_component(self, component): - """Remove a component from the system. + def remove_component(self, component: str | Component) -> NoReturn: + """ + Remove a component from the system. Parameters ---------- - component : {str, Component} + component : str | Component The name of the component or the component instance to be removed. Raises @@ -310,8 +358,8 @@ def remove_component(self, component): self._components.remove(component) @property - def indices(self): - """dict: List of species indices for each component name.""" + def indices(self) -> dict[str, list[int]]: + """dict[str, list[int]]: List of species indices for each component name.""" indices = defaultdict(list) index = 0 @@ -323,8 +371,8 @@ def indices(self): return Dict(indices) @property - def species_indices(self): - """dict: Indices for each species.""" + def species_indices(self) -> dict[str, int]: + """dict[str, int]: Indices for each species.""" indices = Dict() index = 0 @@ -336,8 +384,8 @@ def species_indices(self): return indices @property - def names(self): - """list: List of component names.""" + def names(self) -> list[str]: + """list[str]: List of component names.""" names = [ comp.name if comp.name is not None else str(i) for i, comp in enumerate(self.components) @@ -346,8 +394,8 @@ def names(self): return names @property - def species(self): - """list: List of species names.""" + def species(self) -> list[str]: + """list[str]: List of species names.""" species = [] index = 0 for comp in self.components: @@ -362,8 +410,8 @@ def species(self): return species @property - def charges(self): - """list: List of species charges.""" + def charges(self) -> list[int | None]: + """list[int | None]: List of species charges.""" charges = [] for comp in self.components: charges += comp.charge @@ -371,19 +419,31 @@ def charges(self): return charges @property - def molecular_weights(self): - """list: List of species molecular weights.""" + def molecular_weights(self) -> list[float | None]: + """list[float | None]: List of species molecular weights.""" molecular_weights = [] for comp in self.components: molecular_weights += comp.molecular_weight return molecular_weights - def __repr__(self): + @property + def densities(self) -> list[float | None]: + """list[float | None]: List of species densities.""" + densities = [] + for comp in self.components: + densities += comp.density + + return densities + + def __repr__(self) -> str: + """str: Return the string representation of the object.""" return f'{self.__class__.__name__}({self.names})' def __iter__(self): + """Iterator over components in the system.""" yield from self.components - def __getitem__(self, item): + def __getitem__(self, item: int) -> Component: + """Component: Retrieve a component by its index.""" return self._components[item] diff --git a/CADETProcess/processModel/discretization.py b/CADETProcess/processModel/discretization.py index aeebfec3..60b92c56 100644 --- a/CADETProcess/processModel/discretization.py +++ b/CADETProcess/processModel/discretization.py @@ -12,9 +12,9 @@ class for all other classes in this module and defines some common parameters. Specific parameters for each scheme are defined as attributes of each class. """ -from CADETProcess.dataStructure import Structure +from CADETProcess.dataStructure import Structure, frozen_attributes from CADETProcess.dataStructure import ( - Bool, Switch, + Constant, Bool, Switch, RangedInteger, UnsignedInteger, UnsignedFloat, SizedRangedList ) @@ -26,10 +26,12 @@ class for all other classes in this module and defines some common parameters. 'LRMPDiscretizationFV', 'LRMPDiscretizationDG', 'GRMDiscretizationFV', 'GRMDiscretizationDG', 'WenoParameters', 'ConsistencySolverParameters', - 'DGMixin' + 'DGMixin', + 'MCTDiscretizationFV', ] +@frozen_attributes class DiscretizationParametersBase(Structure): """Base class for storing discretization parameters. @@ -43,7 +45,6 @@ class DiscretizationParametersBase(Structure): Consistency solver parameters for Cadet. """ - _dimensionality = [] def __init__(self): @@ -104,9 +105,6 @@ class DGMixin(DiscretizationParametersBase): class LRMDiscretizationFV(DiscretizationParametersBase): """Discretization parameters of the FV version of the LRM. - This class stores parameters for the Lax-Richtmyer-Morton (LRM) flux-based - finite volume discretization. - Attributes ---------- ncol : UnsignedInteger, optional @@ -121,12 +119,13 @@ class LRMDiscretizationFV(DiscretizationParametersBase): """ + spatial_method = Constant(value='FV') ncol = UnsignedInteger(default=100) use_analytic_jacobian = Bool(default=True) reconstruction = Switch(default='WENO', valid=['WENO']) _parameters = DiscretizationParametersBase._parameters + [ - 'ncol', 'use_analytic_jacobian', 'reconstruction', + 'spatial_method', 'ncol', 'use_analytic_jacobian', 'reconstruction', ] _dimensionality = ['ncol'] @@ -136,19 +135,14 @@ class LRMDiscretizationDG(DGMixin): Attributes ---------- - ncol : UnsignedInteger, optional - Number of axial column discretization cells. Default is 16. + nelem : UnsignedInteger, optional + Number of axial column elements. Default is 16. use_analytic_jacobian : Bool, optional If True, use analytically computed Jacobian matrix (faster). If False, use Jacobians generated by algorithmic differentiation (slower). Default is True. - reconstruction : Switch, optional - Method for spatial reconstruction. Valid values are 'WENO' (Weighted - Essentially Non-Oscillatory). Default is 'WENO'. - polynomial_degree : UnsignedInteger, optional - Degree of the polynomial used for axial discretization. Default is 3. polydeg : UnsignedInteger, optional - Degree of the polynomial used for axial discretization. Default is 3. + Degree of the polynomial used for axial discretization. Default is 4. exact_integration : Bool, optional Whether to use exact integration for the axial discretization. Default is False. @@ -160,15 +154,14 @@ class LRMDiscretizationDG(DGMixin): """ - ncol = UnsignedInteger(default=16) + spatial_method = Constant(value='DG') + nelem = UnsignedInteger(default=16) use_analytic_jacobian = Bool(default=True) - reconstruction = Switch(default='WENO', valid=['WENO']) - polynomial_degree = UnsignedInteger(default=3) - polydeg = polynomial_degree + polydeg = UnsignedInteger(default=4) exact_integration = Bool(default=False) _parameters = DiscretizationParametersBase._parameters + [ - 'ncol', 'use_analytic_jacobian', 'reconstruction', + 'spatial_method', 'nelem', 'use_analytic_jacobian', 'polydeg', 'exact_integration' ] _dimensionality = ['axial_dof'] @@ -176,7 +169,7 @@ class LRMDiscretizationDG(DGMixin): @property def axial_dof(self): """int: Number of degrees of freedom in the axial discretization.""" - return self.ncol * (self.polynomial_degree + 1) + return self.nelem * (self.polydeg + 1) class LRMPDiscretizationFV(DiscretizationParametersBase): @@ -218,6 +211,7 @@ class LRMPDiscretizationFV(DiscretizationParametersBase): """ + spatial_method = Constant(value='FV') ncol = UnsignedInteger(default=100) par_geom = Switch( @@ -234,7 +228,7 @@ class LRMPDiscretizationFV(DiscretizationParametersBase): schur_safety = UnsignedFloat(default=1.0e-8) _parameters = DiscretizationParametersBase._parameters + [ - 'ncol', 'par_geom', + 'spatial_method', 'ncol', 'par_geom', 'use_analytic_jacobian', 'reconstruction', 'gs_type', 'max_krylov', 'max_restarts', 'schur_safety' ] @@ -246,8 +240,8 @@ class LRMPDiscretizationDG(DGMixin): Attributes ---------- - ncol : UnsignedInteger, optional - Number of axial column discretization cells. Default is 16. + nelem : UnsignedInteger, optional + Number of axial column elements. Default is 16. par_geom : Switch, optional The geometry of the particles in the model. Valid values are 'SPHERE', 'CYLINDER', and 'SLAB'. @@ -256,29 +250,11 @@ class LRMPDiscretizationDG(DGMixin): If True, use analytically computed Jacobian matrix (faster). If False, use Jacobians generated by algorithmic differentiation (slower). Default is True. - reconstruction : Switch, optional - Method for spatial reconstruction. Valid values are 'WENO' (Weighted - Essentially Non-Oscillatory). Default is 'WENO'. - polynomial_degree : UnsignedInteger, optional - Degree of the polynomial used for spatial discretization. Default is 3. polydeg : UnsignedInteger, optional - Alias for polynomial_degree. + Degree of the polynomial used for spatial discretization. Default is 4. exact_integration : Bool, optional Whether to use exact integration for the spatial discretization. Default is False. - gs_type : Bool, optional - Type of Gram-Schmidt orthogonalization. - If 0, use classical Gram-Schmidt. - If 1, use modified Gram-Schmidt. - The default is 1. - max_krylov : UnsignedInteger, optional - Size of the Krylov subspace in the iterative linear GMRES solver. - If 0, max_krylov = NCOL * NCOMP * NPARTYPE is used. - The default is 0. - max_restarts : UnsignedInteger, optional - Maximum number of restarts to use for the GMRES method. Default is 10. - schur_safety : UnsignedFloat, optional - Safety factor for the Schur complement solver. Default is 1.0e-8. See Also -------- @@ -287,7 +263,8 @@ class LRMPDiscretizationDG(DGMixin): """ - ncol = UnsignedInteger(default=16) + spatial_method = Constant(value='DG') + nelem = UnsignedInteger(default=16) par_geom = Switch( default='SPHERE', @@ -295,28 +272,20 @@ class LRMPDiscretizationDG(DGMixin): ) use_analytic_jacobian = Bool(default=True) - reconstruction = Switch(default='WENO', valid=['WENO']) - polynomial_degree = UnsignedInteger(default=3) - polydeg = polynomial_degree + polydeg = UnsignedInteger(default=4) exact_integration = Bool(default=False) - gs_type = Bool(default=True) - max_krylov = UnsignedInteger(default=0) - max_restarts = UnsignedInteger(default=10) - schur_safety = UnsignedFloat(default=1.0e-8) - _parameters = DiscretizationParametersBase._parameters + [ - 'ncol', 'par_geom', - 'use_analytic_jacobian', 'reconstruction', + 'spatial_method', 'nelem', 'par_geom', + 'use_analytic_jacobian', 'polydeg', 'exact_integration', - 'gs_type', 'max_krylov', 'max_restarts', 'schur_safety' ] _dimensionality = ['axial_dof'] @property def axial_dof(self): """int: Number of axial degrees of freedom in the spatial discretization.""" - return self.ncol * (self.polynomial_degree + 1) + return self.nelem * (self.polydeg + 1) class GRMDiscretizationFV(DiscretizationParametersBase): @@ -380,6 +349,7 @@ class GRMDiscretizationFV(DiscretizationParametersBase): """ + spatial_method = Constant(value='FV') ncol = UnsignedInteger(default=100) npar = UnsignedInteger(default=5) @@ -408,7 +378,7 @@ class GRMDiscretizationFV(DiscretizationParametersBase): fix_zero_surface_diffusion = Bool(default=False) _parameters = DiscretizationParametersBase._parameters + [ - 'ncol', 'npar', + 'spatial_method', 'ncol', 'npar', 'par_geom', 'par_disc_type', 'par_disc_vector', 'par_boundary_order', 'use_analytic_jacobian', 'reconstruction', 'gs_type', 'max_krylov', 'max_restarts', 'schur_safety', @@ -434,13 +404,11 @@ class GRMDiscretizationDG(DGMixin): Attributes ---------- - ncol : UnsignedInteger, optional - Number of axial column discretization cells. Default is 16. - npar : UnsignedInteger, optional + nelem : UnsignedInteger, optional + Number of axial column elements. Default is 16. + par_nelem : UnsignedInteger, optional Number of particle (radial) discretization cells for each particle type. Default is 1. - nparcell : UnsignedInteger, optional - Alias for npar par_geom : Switch, optional The geometry of the particles in the model. Valid values are 'SPHERE', 'CYLINDER', and 'SLAB'. @@ -449,34 +417,16 @@ class GRMDiscretizationDG(DGMixin): If True, use analytically computed Jacobian matrix (faster). If False, use Jacobians generated by algorithmic differentiation (slower). Default is True. - reconstruction : Switch, optional - Method for spatial reconstruction. Valid values are 'WENO' (Weighted - Essentially Non-Oscillatory). Default is 'WENO'. - polynomial_degree : UnsignedInteger, optional - Degree of the polynomial used for axial discretization. Default is 3. polydeg : UnsignedInteger, optional - Alias for polynomial_degree. - polynomial_degree_particle : UnsignedInteger, optional - Degree of the polynomial used for particle radial discretization. Default is 3. + Degree of the polynomial used for axial discretization. Default is 4. + par_polydeg : UnsignedInteger, optional + Degree of the polynomial used for particle radial discretization. Default is 4. exact_integration : Bool, optional Whether to use exact integration for the axial discretization. Default is False. - exact_integration : Bool, optional + par_exact_integration : Bool, optional Whether to use exact integration for the particle radial discretization. - Default is False. - gs_type : Bool, optional - Type of Gram-Schmidt orthogonalization. - If 0, use classical Gram-Schmidt. - If 1, use modified Gram-Schmidt. - The default is 1. - max_krylov : UnsignedInteger, optional - Size of the Krylov subspace in the iterative linear GMRES solver. - If 0, max_krylov = NCOL * NCOMP * NPARTYPE is used. - The default is 0. - max_restarts : UnsignedInteger, optional - Maximum number of restarts to use for the GMRES method. Default is 10. - schur_safety : UnsignedFloat, optional - Safety factor for the Schur complement solver. Default is 1.0e-8. + Default is True. fix_zero_surface_diffusion : Bool, optional Whether to fix zero surface diffusion for particles. Default is False. If True, the parameters must not become non-zero during this or subsequent @@ -490,10 +440,9 @@ class GRMDiscretizationDG(DGMixin): CADETProcess.processModel.GeneralRateModel """ - - ncol = UnsignedInteger(default=16) - npar = UnsignedInteger(default=1) - nparcell = npar + spatial_method = Constant(value='DG') + nelem = UnsignedInteger(default=16) + par_nelem = UnsignedInteger(default=1) par_geom = Switch( default='SPHERE', @@ -510,29 +459,19 @@ class GRMDiscretizationDG(DGMixin): par_boundary_order = RangedInteger(lb=1, ub=2, default=2) use_analytic_jacobian = Bool(default=True) - reconstruction = Switch(default='WENO', valid=['WENO']) - polynomial_degree = UnsignedInteger(default=3) - polydeg = polynomial_degree - polynomial_degree_particle = UnsignedInteger(default=3) - parpolydeg = polynomial_degree_particle + polydeg = UnsignedInteger(default=4) + par_polydeg = UnsignedInteger(default=4) exact_integration = Bool(default=False) - exact_integration_particle = Bool(default=True) - par_exact_integration = exact_integration_particle - - gs_type = Bool(default=True) - max_krylov = UnsignedInteger(default=0) - max_restarts = UnsignedInteger(default=10) - schur_safety = UnsignedFloat(default=1.0e-8) + par_exact_integration = Bool(default=True) fix_zero_surface_diffusion = Bool(default=False) _parameters = DiscretizationParametersBase._parameters + [ - 'ncol', 'nparcell', + 'spatial_method', 'nelem', 'par_nelem', 'par_geom', 'par_disc_type', 'par_disc_vector', 'par_boundary_order', - 'use_analytic_jacobian', 'reconstruction', - 'polydeg', 'parpolydeg', + 'use_analytic_jacobian', + 'polydeg', 'par_polydeg', 'exact_integration', 'par_exact_integration', - 'gs_type', 'max_krylov', 'max_restarts', 'schur_safety', 'fix_zero_surface_diffusion', ] _dimensionality = ['axial_dof', 'par_dof'] @@ -540,19 +479,20 @@ class GRMDiscretizationDG(DGMixin): @property def axial_dof(self): """int: Number of axial degrees of freedom in the axial discretization.""" - return self.ncol * (self.polynomial_degree + 1) + return self.nelem * (self.polydeg + 1) @property def par_dof(self): """int: Number of particle degrees of freedom in the axial discretization.""" - return self.ncol * (self.polynomial_degree_particle + 1) + return self.axial_dof * self.par_disc_vector_length @property def par_disc_vector_length(self): """int: Number of entries in the particle discretization vector.""" - return self.npar + 1 + return self.par_nelem * (self.par_polydeg + 1) +@frozen_attributes class WenoParameters(Structure): """Discretization parameters for the WENO scheme. @@ -588,6 +528,7 @@ class WenoParameters(Structure): _parameters = ['boundary_model', 'weno_eps', 'weno_order'] +@frozen_attributes class ConsistencySolverParameters(Structure): """A class for defining the consistency solver parameters for Cadet. @@ -631,3 +572,30 @@ class ConsistencySolverParameters(Structure): 'solver_name', 'init_damping', 'min_damping', 'max_iterations', 'subsolvers' ] + + +class MCTDiscretizationFV(DiscretizationParametersBase): + """Discretization parameters of the FV version of the MCT. + + Attributes + ---------- + ncol : UnsignedInteger, optional + Number of axial column discretization cells. Default is 100. + use_analytic_jacobian : Bool, optional + If True, use analytically computed Jacobian matrix (faster). + If False, use Jacobians generated by algorithmic differentiation (slower). + Default is True. + reconstruction : Switch, optional + Method for spatial reconstruction. Valid values are 'WENO' (Weighted + Essentially Non-Oscillatory). Default is 'WENO'. + + """ + + ncol = UnsignedInteger(default=100) + use_analytic_jacobian = Bool(default=True) + reconstruction = Switch(default='WENO', valid=['WENO']) + + _parameters = [ + 'ncol', 'use_analytic_jacobian', 'reconstruction', + ] + _dimensionality = ['ncol'] diff --git a/CADETProcess/processModel/flowSheet.py b/CADETProcess/processModel/flowSheet.py index 29fdd4ea..2badae1d 100644 --- a/CADETProcess/processModel/flowSheet.py +++ b/CADETProcess/processModel/flowSheet.py @@ -1,5 +1,6 @@ from functools import wraps from warnings import warn +from collections import defaultdict import numpy as np from addict import Dict @@ -176,6 +177,32 @@ def get_unit_index(self, unit): return self.units.index(unit) + def get_port_index(self, unit, port): + """Return the port index of a unit + + Parameters + ---------- + unit : UnitBaseClass + UnitBaseClass object of wich port index is to be returned + port : string + Name of port which index is to be returned + Raises + ------ + CADETProcessError + If unit or port is not in the current flow sheet. + Returns + ------- + port_index : int + Returns the port index of the port of the unit_operation. + """ + if unit not in self.units: + raise CADETProcessError('Unit not in flow sheet') + + + port_index = unit.ports.index(port) + + return port_index + @property def inlets(self): """list: All Inlets in the system.""" @@ -237,11 +264,24 @@ def add_unit( raise CADETProcessError('Component systems do not match.') self._units.append(unit) - self._connections[unit] = Dict({ - 'origins': [], - 'destinations': [], - }) - self._output_states[unit] = [] + + + for port in unit.ports: + + if isinstance(unit, Inlet): + self._connections[unit]['origins'] = None + self._connections[unit]['destinations'][port] = defaultdict(list) + + elif isinstance(unit, Outlet): + self._connections[unit]['origins'][port] = defaultdict(list) + self._connections[unit]['destinations'] = None + + else: + self._connections[unit]['origins'][port] = defaultdict(list) + self._connections[unit]['destinations'][port] = defaultdict(list) + + + self._output_states[unit] = Dict() self._flow_rates[unit] = [] super().__setattr__(unit.name, unit) @@ -292,13 +332,24 @@ def remove_unit(self, unit): if unit is self.product_outlets: self.remove_product_outlet(unit) - origins = self.connections[unit].origins.copy() + origins = [] + destinations = [] + + if self._connections[unit]['origins'] is not None: + origins = [origin for ports in self._connections[unit]['origins'] for origin in self._connections[unit]['origins'][ports]] + + if self._connections[unit]['destinations'] is not None: + destinations = [destination for ports in self._connections[unit]['destinations'] for destination in self._connections[unit]['destinations'][ports]].copy() + for origin in origins: - self.remove_connection(origin, unit) + for origin_port in self._connections[unit]['origins']: + for unit_port in self._connections[unit]['origins'][origin_port][origin]: + self.remove_connection(origin, unit, origin_port, unit_port) - destinations = self.connections[unit].destinations.copy() for destination in destinations: - self.remove_connection(unit, destination) + for destination_port in self._connections[unit]['destinations']: + for unit_port in self._connections[unit]['destinations'][destination_port][destination]: + self.remove_connection(unit, destination, unit_port, destination_port) self._units.remove(unit) self._connections.pop(unit) @@ -319,7 +370,8 @@ def connections(self): @origin_destination_name_decorator @update_parameters_decorator - def add_connection(self, origin, destination): + def add_connection( + self, origin, destination, origin_port=None, destination_port=None): """Add connection between units 'origin' and 'destination'. Parameters @@ -328,6 +380,10 @@ def add_connection(self, origin, destination): UnitBaseClass from which the connection originates. destination : UnitBaseClass UnitBaseClass where the connection terminates. + origin_port : str + Port from which connection originates. + destination_port : str + Port where connection terminates. Raises ------ @@ -344,20 +400,51 @@ def add_connection(self, origin, destination): """ if origin not in self._units: raise CADETProcessError('Origin not in flow sheet') + if isinstance(origin, Outlet): + raise CADETProcessError("Outlet unit cannot have outgoing stream.") + if destination not in self._units: raise CADETProcessError('Destination not in flow sheet') + if isinstance(destination, Inlet): + raise CADETProcessError("Inlet unit cannot have ingoing stream.") + + + if origin.has_ports and origin_port is None: + raise CADETProcessError('Missing `origin_port`') + if not origin.has_ports and origin_port is not None: + raise CADETProcessError(f'Origin unit does not support ports.') + if origin.has_ports and origin_port not in origin.ports: + raise CADETProcessError(f'Origin port "{origin_port}" not found in ports: {origin.ports}.') + if origin_port in self._connections[destination]['origins'][destination_port][origin]: + raise CADETProcessError("Connection already exists") + + + + if destination.has_ports and destination_port is None: + raise CADETProcessError('Missing `destination_port`') + if not destination.has_ports and destination_port is not None: + raise CADETProcessError('Destination unit does not support ports.') + if destination.has_ports and destination_port not in destination.ports: + raise CADETProcessError(f'destination port "{destination_port}" not found in ports: {destination.ports}.') + + if destination_port in self._connections[origin]['destinations'][origin_port][destination]: + raise CADETProcessError("Connection already exists") + - if destination in self.connections[origin].destinations: - raise CADETProcessError('Connection already exists') + if not destination.has_ports: + destination_port = destination.ports[0] + if not origin.has_ports: + origin_port = origin.ports[0] - self._connections[origin].destinations.append(destination) - self._connections[destination].origins.append(origin) + self._connections[destination]['origins'][destination_port][origin].append(origin_port) + self._connections[origin]['destinations'][origin_port][destination].append(destination_port) + + self.set_output_state(origin, 0, origin_port) - self.set_output_state(origin, 0) @origin_destination_name_decorator @update_parameters_decorator - def remove_connection(self, origin, destination): + def remove_connection( self, origin, destination, origin_port=None, destination_port=None): """Remove connection between units 'origin' and 'destination'. Parameters @@ -366,6 +453,10 @@ def remove_connection(self, origin, destination): UnitBaseClass from which the connection originates. destination : UnitBaseClass UnitBaseClass where the connection terminates. + origin_port : int + Port from which connection originates. + destination_port : int + Port where connection terminates. Raises ------ @@ -381,17 +472,31 @@ def remove_connection(self, origin, destination): """ if origin not in self._units: raise CADETProcessError('Origin not in flow sheet') + if origin.has_ports and origin_port is None: + raise CADETProcessError('Missing `origin_port`') + + if origin_port not in origin.ports: + raise CADETProcessError(f'Origin port {origin_port} not in Unit {origin.name}.') + if destination not in self._units: raise CADETProcessError('Destination not in flow sheet') + if destination.has_ports and origin_port is None: + raise CADETProcessError('Missing `destination_port`') + + if destination_port not in destination.ports: + raise CADETProcessError(f'Origin port {destination_port} not in Unit {destination.name}.') try: - self._connections[origin].destinations.remove(destination) - self._connections[destination].origins.remove(origin) + + self._connections[destination]['origins'][destination_port].pop(origin) + self._connections[origin]['destinations'][origin_port].pop(destination) except KeyError: raise CADETProcessError('Connection does not exist.') + + @origin_destination_name_decorator - def connection_exists(self, origin, destination): + def connection_exists(self, origin, destination, origin_port=None, destination_port=None): """bool: check if connection exists in flow sheet. Parameters @@ -402,8 +507,22 @@ def connection_exists(self, origin, destination): UnitBaseClass where the connection terminates. """ - if destination in self._connections[origin].destinations \ - and origin in self._connections[destination].origins: + if origin.has_ports and not origin_port: + raise CADETProcessError(f'Origin port needs to be specified for unit operation with ports {origin.name}.') + + if destination.has_ports and not destination_port: + raise CADETProcessError(f'Destination port needs to be specified for unit operation with ports {destination.name}.') + + if origin_port not in origin.ports: + raise CADETProcessError(f'{origin.name} does not have port {origin_port}') + + if destination_port not in destination.ports: + raise CADETProcessError(f'{destination.name} does not have port {destination_port}') + + if destination in self._connections[origin].destinations[origin_port]\ + and destination_port in self._connections[origin].destinations[origin_port][destination]\ + and origin in self._connections[destination].origins[destination_port]\ + and origin_port in self._connections[destination].origins[destination_port][origin]: return True return False @@ -427,14 +546,14 @@ def check_connections(self): flag = True for unit, connections in self.connections.items(): if isinstance(unit, Inlet): - if len(connections.origins) != 0: + if connections.origins != None: flag = False warn("Inlet unit cannot have ingoing stream.") if len(connections.destinations) == 0: flag = False warn(f" Unit '{unit.name}' does not have outgoing stream.") elif isinstance(unit, Outlet): - if len(connections.destinations) != 0: + if connections.destinations != None: flag = False warn("Outlet unit cannot have outgoing stream.") if len(connections.origins) == 0: @@ -445,10 +564,10 @@ def check_connections(self): flag = False warn("Cstr cannot have flow rate without outgoing stream.") else: - if len(connections.origins) == 0: + if all(len(port_list) == 0 for port_list in connections.origins.values()): flag = False warn(f"Unit '{unit.name}' does not have ingoing stream.") - if len(connections.destinations) == 0: + if all(len(port_list) == 0 for port_list in connections.destinations.values()): flag = False warn(f" Unit '{unit.name}' does not have outgoing stream.") @@ -482,11 +601,20 @@ def check_units_config(self): @property def output_states(self): - return self._output_states + + output_states_dict = self._output_states.copy() + + for unit, ports in output_states_dict.items(): + for port in ports: + if port == None: + output_states_dict[unit] = output_states_dict[unit][port] + + + return output_states_dict @unit_name_decorator @update_parameters_decorator - def set_output_state(self, unit, state): + def set_output_state(self, unit, state, port=None): """Set split ratio of outgoing streams for UnitOperation. Parameters @@ -495,6 +623,8 @@ def set_output_state(self, unit, state): UnitOperation of flowsheet. state : int or list of floats or dict new output state of the unit. + port : int + Port for which to set the output state. Raises ------ @@ -503,12 +633,40 @@ def set_output_state(self, unit, state): If state is integer and the state >= the state_length. If the length of the states is unequal the state_length. If the sum of the states is not equal to 1. - + If port exceeds the number of ports """ + def get_port_index(unit_connection_dict, destination, destination_port): + """helper function to classify the index of a connection for your outputstate + + Parameters + ---------- + + unit_connection_dict : Defaultdict + contains dict with connected units and their respective ports + destination : UnitBaseClass + destination object + destination_port : int + destination Port + + """ + + ret_index = 0 + for unit_destination in unit_connection_dict: + if unit_destination is destination: + ret_index+=unit_connection_dict[unit_destination].index(destination_port) + break + ret_index+=len(unit_connection_dict[unit_destination]) + return ret_index + + if unit not in self._units: raise CADETProcessError('Unit not in flow sheet') - state_length = len(self.connections[unit].destinations) + if port not in unit.ports: + raise CADETProcessError(f'Port {port} is not a port of Unit {unit.name}') + + + state_length = sum([len(self._connections[unit].destinations[port][unit_name]) for unit_name in self._connections[unit].destinations[port]]) if state_length == 0: output_state = [] @@ -524,18 +682,34 @@ def set_output_state(self, unit, state): output_state = [0.0] * state_length for dest, value in state.items(): try: - assert self.connection_exists(unit, dest) - except AssertionError: - raise CADETProcessError(f'{unit} does not connect to {dest}.') - dest = self[dest] - index = self.connections[unit].destinations.index(dest) - output_state[index] = value - - elif isinstance(state, list): + for destination_port, output_value in value.items(): + try: + assert self.connection_exists(unit, dest, port, destination_port) + except AssertionError: + raise CADETProcessError(f'{unit} on port {port} does not connect to {dest} on port {destination_port}.') + inner_dest = self[dest] + index = get_port_index(self._connections[unit].destinations[port], inner_dest, destination_port) + output_state[index] = output_value + except AttributeError: + destination_port = None + try: + assert self.connection_exists(unit, dest, port, destination_port) + except AssertionError: + raise CADETProcessError(f'{unit} on port {port} does not connect to {dest} on port {destination_port}.') + dest = self[dest] + index = get_port_index(self.connections[unit].destinations[port], dest, destination_port) + output_state[index] = value + + elif isinstance(state, (list)): if len(state) != state_length: raise CADETProcessError(f'Expected length {state_length}.') output_state = state + elif isinstance(state, np.ndarray): + if len(state) != state_length: + raise CADETProcessError(f'Expected length {state_length}.') + + output_state = list(state) else: raise TypeError("Output state must be integer, list or dict.") @@ -543,18 +717,21 @@ def set_output_state(self, unit, state): if state_length != 0 and not np.isclose(sum(output_state), 1): raise CADETProcessError('Sum of fractions must be 1') - self._output_states[unit] = output_state + self._output_states[unit][port] = output_state - def get_flow_rates(self, state=None): - """ - Calculate the volumetric flow rate for all connections in the process. + def get_flow_rates(self, state=None, eps=5.9e16): + """Calculate flow rate for all connections. + + Optionally, an additional output state can be passed to update the + current output states. Parameters ---------- state : Dict, optional Updated flow rates and output states for process sections. Default is None. - + eps : float, optional + eps as an upper boarder for condition of flow_rate calculation Returns ------- Dict @@ -585,43 +762,74 @@ def get_flow_rates(self, state=None): Forum discussion on flow rate calculation: https://forum.cadet-web.de/t/improving-the-flowrate-calculation/795 """ + port_index_list = [] + port_number = 0 + + for unit in self.units: + for port in unit.ports: + port_index_list.append((unit, port)) + port_number += 1 + flow_rates = { unit.name: unit.flow_rate for unit in (self.inlets + self.cstrs) if unit.flow_rate is not None } - output_states = self.output_states + output_states = self._output_states if state is not None: for param, value in state.items(): param = param.split('.') - unit_name = param[1] param_name = param[-1] if param_name == 'flow_rate': + unit_name = param[1] flow_rates[unit_name] = value[0] - elif unit_name == 'output_states': - unit = self.units_dict[param_name] - output_states[unit] = list(value.ravel()) - - n_units = self.number_of_units + elif param[1] == 'output_states': + unit_name = param[2] + unit = self.units_dict[unit_name] + if unit.has_ports: + port = param[2] + else: + port = None + output_states[unit][port] = list(value.ravel()) # Setup matrix with output states. - w_out = np.zeros((n_units, n_units)) + w_out = np.zeros((port_number, port_number)) for unit in self.units: - unit_index = self.get_unit_index(unit) + if unit.name in flow_rates: + unit_index = port_index_list.index((unit, None)) w_out[unit_index, unit_index] = 1 else: - for origin in self.connections[unit]['origins']: - o_index = self.get_unit_index(origin) - local_d_index = self.connections[origin].destinations.index(unit) - w_out[unit_index, o_index] = output_states[origin][local_d_index] - w_out[unit_index, unit_index] += -1 + + for port in self._connections[unit]['origins']: + + port_index = port_index_list.index((unit, port)) + + for origin_unit in self._connections[unit]['origins'][port]: + + for origin_port in self._connections[unit]['origins'][port][origin_unit]: + + o_index = port_index_list.index((origin_unit, origin_port)) + + local_d_index = 0 + + for inner_unit in self._connections[origin_unit]['destinations'][origin_port]: + if inner_unit == unit: + break + local_d_index += len(list(self._connections[origin_unit]['destinations'][origin_port][inner_unit])) + + local_d_index += self._connections[origin_unit]['destinations'][origin_port][unit].index(port) + + if output_states[origin_unit][origin_port][local_d_index]: + w_out[port_index, o_index] = output_states[origin_unit][origin_port][local_d_index] + + w_out[port_index, port_index] += -1 # Check for a singular matrix before the loop - if np.linalg.cond(w_out) == np.inf: + if np.linalg.cond(w_out) > eps: raise CADETProcessError( "Flow sheet connectivity matrix is singular, which may be due to " "unconnected units or missing flow rates. Please ensure all units are " @@ -629,7 +837,7 @@ def get_flow_rates(self, state=None): ) # Solve system of equations for each polynomial coefficient - total_flow_rate_coefficents = np.zeros((4, n_units)) + total_flow_rate_coefficents = np.zeros((4, port_number)) for i in range(4): if len(flow_rates) == 0: continue @@ -638,10 +846,10 @@ def get_flow_rates(self, state=None): if not np.any(coeffs): continue - Q_vec = np.zeros(n_units) + Q_vec = np.zeros(port_number) for unit_name in flow_rates: - unit_index = self.get_unit_index(self.units_dict[unit_name]) - Q_vec[unit_index] = flow_rates[unit_name][i] + port_index = port_index_list.index((self.units_dict[unit_name], None)) + Q_vec[port_index] = flow_rates[unit_name][i] try: total_flow_rate_coefficents[i, :] = np.linalg.solve(w_out, Q_vec) except np.linalg.LinAlgError: @@ -651,50 +859,103 @@ def get_flow_rates(self, state=None): ) # w_out_help is the same as w_out but it contains the origin flow for every unit - w_out_help = np.zeros((n_units, n_units)) + w_out_help = np.zeros((port_number, port_number)) + + + for unit in self.units: + if self._connections[unit]['origins']: + for port in self._connections[unit]['origins']: - for unit in self.connections: - unit_index = self.get_unit_index(unit) - for origin in self.connections[unit]['origins']: - o_index = self.get_unit_index(origin) - local_d_index = self.connections[origin].destinations.index(unit) - w_out_help[unit_index, o_index] = output_states[origin][local_d_index] + port_index = port_index_list.index((unit, port)) + + for origin_unit in self._connections[unit]['origins'][port]: + + for origin_port in self._connections[unit]['origins'][port][origin_unit]: + o_index = port_index_list.index((origin_unit, origin_port)) + + local_d_index = 0 + + for inner_unit in self._connections[origin_unit]['destinations'][origin_port]: + if inner_unit == unit: + break + local_d_index += len(list(self._connections[origin_unit]['destinations'][origin_port][inner_unit])) + + local_d_index += self._connections[origin_unit]['destinations'][origin_port][unit].index(port) + + if not output_states[origin_unit][origin_port][local_d_index]: + w_out_help[port_index, o_index] = 0 + else : + w_out_help[port_index, o_index] = output_states[origin_unit][origin_port][local_d_index] # Calculate total_in as a matrix in "one" step rather than iterating manually. total_in_matrix = w_out_help @ total_flow_rate_coefficents.T # Generate output dict return_flow_rates = Dict() - for index, unit in enumerate(self.units): + for unit in self.units: unit_solution_dict = Dict() if not isinstance(unit, Inlet): - unit_solution_dict['total_in'] = list(total_in_matrix[index]) + + unit_solution_dict['total_in'] = Dict() + + for unit_port in unit.ports: + + index = port_index_list.index((unit, unit_port)) + unit_solution_dict['total_in'][unit_port] = list(total_in_matrix[index]) if not isinstance(unit, Outlet): - unit_solution_dict['total_out'] = list(total_flow_rate_coefficents[:, index]) + + unit_solution_dict['total_out'] = Dict() + + for unit_port in unit.ports: + + index = port_index_list.index((unit, unit_port)) + unit_solution_dict['total_out'][unit_port] = list(total_flow_rate_coefficents[:, index]) + if not isinstance(unit, Inlet): - unit_solution_dict['origins'] = Dict( - { - origin.name: list( - total_flow_rate_coefficents[:, self.get_unit_index(origin)] - * w_out_help[index, self.get_unit_index(origin)] - ) - for origin in self.connections[unit].origins - } - ) + unit_solution_dict['origins'] = Dict() + + for unit_port in self._connections[unit]['origins']: + + if self._connections[unit]['origins'][unit_port]: + + unit_solution_dict['origins'][unit_port] = Dict() + + for origin_unit in self._connections[unit]['origins'][unit_port]: + + unit_solution_dict['origins'][unit_port][origin_unit.name] = Dict() + + for origin_port in self._connections[unit]['origins'][unit_port][origin_unit]: + origin_port_index = port_index_list.index((origin_unit, origin_port)) + unit_port_index = port_index_list.index((unit, unit_port)) + flow_list = list( + total_flow_rate_coefficents[:, origin_port_index] + * w_out_help[unit_port_index, origin_port_index]) + unit_solution_dict['origins'][unit_port][origin_unit.name][origin_port] = flow_list if not isinstance(unit, Outlet): - unit_solution_dict['destinations'] = Dict( - { - destination.name: list( - total_flow_rate_coefficents[:, index] - * w_out_help[self.get_unit_index(destination), index] - ) - for destination in self.connections[unit].destinations - } - ) + + unit_solution_dict['destinations'] = Dict() + + for unit_port in self._connections[unit]['destinations']: + + if self._connections[unit]['destinations'][unit_port]: + + unit_solution_dict['destinations'][unit_port] = Dict() + + for destination_unit in self._connections[unit]['destinations'][unit_port]: + + unit_solution_dict['destinations'][unit_port][destination_unit.name] = Dict() + + for destination_port in self._connections[unit]['destinations'][unit_port][destination_unit]: + destination_port_index = port_index_list.index((destination_unit, destination_port)) + unit_port_index = port_index_list.index((unit, unit_port)) + flow_list = list( + total_flow_rate_coefficents[:, unit_port_index] + * w_out_help[destination_port_index, unit_port_index]) + unit_solution_dict['destinations'][unit_port][destination_unit.name][destination_port] = flow_list return_flow_rates[unit.name] = unit_solution_dict @@ -866,7 +1127,12 @@ def parameters(self, parameters): output_states = parameters.pop('output_states') for unit, state in output_states.items(): unit = self.units_dict[unit] - self.set_output_state(unit, state) + # Hier, if unit.has_ports: iterate. + if not unit.has_ports: + self.set_output_state(unit, state) + else: + for port_i, state_i in state.items(): + self.set_output_state(unit, state_i, port_i) except KeyError: pass diff --git a/CADETProcess/processModel/process.py b/CADETProcess/processModel/process.py index 8efb48cf..04ddbf91 100644 --- a/CADETProcess/processModel/process.py +++ b/CADETProcess/processModel/process.py @@ -90,7 +90,7 @@ def m_feed(self): feed_all = np.zeros((self.n_comp,)) for feed in self.flow_sheet.feed_inlets: - feed_flow_rate_time_line = flow_rate_timelines[feed.name].total_out + feed_flow_rate_time_line = flow_rate_timelines[feed.name].total_out[None] feed_signal_param = f'flow_sheet.{feed.name}.c' if feed_signal_param in self.parameter_timelines: tl = self.parameter_timelines[feed_signal_param] @@ -122,7 +122,7 @@ def V_eluent(self): V_all = 0 for eluent in self.flow_sheet.eluent_inlets: - eluent_time_line = flow_rate_timelines[eluent.name]['total_out'] + eluent_time_line = flow_rate_timelines[eluent.name]['total_out'][None] V_eluent = eluent_time_line.integral().squeeze() V_all += V_eluent @@ -140,10 +140,10 @@ def flow_rate_timelines(self): """dict: TimeLine of flow_rate for all unit_operations.""" flow_rate_timelines = { unit.name: { - 'total_in': TimeLine(), - 'origins': defaultdict(TimeLine), - 'total_out': TimeLine(), - 'destinations': defaultdict(TimeLine) + 'total_in': defaultdict(TimeLine), + 'origins': defaultdict( lambda: defaultdict( lambda: defaultdict(TimeLine))), + 'total_out': defaultdict(TimeLine), + 'destinations': defaultdict( lambda: defaultdict( lambda: defaultdict(TimeLine))), } for unit in self.flow_sheet.units } @@ -160,40 +160,55 @@ def flow_rate_timelines(self): flow_rates = self.flow_sheet.get_flow_rates(state) - for unit, flow_rate in flow_rates.items(): + for unit, flow_rate_dict in flow_rates.items(): unit_flow_rates = flow_rate_timelines[unit] # If inlet, also use outlet for total_in + if isinstance(self.flow_sheet[unit], Inlet): - section = Section( - start, end, flow_rate.total_out, is_polynomial=True - ) + for port in flow_rate_dict['total_out']: + section = Section( + start, end, flow_rate_dict.total_out[port], is_polynomial=True + ) + unit_flow_rates['total_in'][port].add_section(section) else: - section = Section( - start, end, flow_rate.total_in, is_polynomial=True - ) - unit_flow_rates['total_in'].add_section(section) - for orig, flow_rate_orig in flow_rate.origins.items(): - section = Section( - start, end, flow_rate_orig, is_polynomial=True - ) - unit_flow_rates['origins'][orig].add_section(section) + for port in flow_rate_dict['total_in']: + section = Section( + start, end, flow_rate_dict.total_in[port], is_polynomial=True + ) + unit_flow_rates['total_in'][port].add_section(section) + + for port in flow_rate_dict.origins: + + for orig, origin_port_dict in flow_rate_dict.origins[port].items(): + for orig_port, flow_rate_orig in origin_port_dict.items(): + section = Section( + start, end, flow_rate_orig, is_polynomial=True + ) + unit_flow_rates['origins'][port][orig][orig_port].add_section(section) # If outlet, also use inlet for total_out + if isinstance(self.flow_sheet[unit], Outlet): - section = Section( - start, end, flow_rate.total_in, is_polynomial=True - ) + for port in flow_rate_dict['total_in']: + section = Section( + start, end, flow_rate_dict.total_in[port], is_polynomial=True + ) + unit_flow_rates['total_out'][port].add_section(section) else: - section = Section( - start, end, flow_rate.total_out, is_polynomial=True - ) - unit_flow_rates['total_out'].add_section(section) - for dest, flow_rate_dest in flow_rate.destinations.items(): - section = Section( - start, end, flow_rate_dest, is_polynomial=True - ) - unit_flow_rates['destinations'][dest].add_section(section) + for port in flow_rate_dict['total_out']: + section = Section( + start, end, flow_rate_dict.total_out[port], is_polynomial=True + ) + unit_flow_rates['total_out'][port].add_section(section) + + for port in flow_rate_dict.destinations: + for dest, dest_port_dict in flow_rate_dict.destinations[port].items(): + for dest_port, flow_rate_dest in dest_port_dict.items(): + section = Section( + start, end, flow_rate_dest, is_polynomial=True + ) + unit_flow_rates['destinations'][port][dest][dest_port].add_section(section) return Dict(flow_rate_timelines) @@ -203,10 +218,10 @@ def flow_rate_section_states(self): section_states = { time: { unit.name: { - 'total_in': [], - 'origins': defaultdict(dict), - 'total_out': [], - 'destinations': defaultdict(dict), + 'total_in': defaultdict(list), + 'origins': defaultdict( lambda: defaultdict( lambda: defaultdict(list))), + 'total_out': defaultdict(list), + 'destinations': defaultdict( lambda: defaultdict( lambda: defaultdict(list))), } for unit in self.flow_sheet.units } for time in self.section_times[0:-1] } @@ -214,26 +229,35 @@ def flow_rate_section_states(self): for sec_time in self.section_times[0:-1]: for unit, unit_flow_rates in self.flow_rate_timelines.items(): if isinstance(self.flow_sheet[unit], Inlet): - section_states[sec_time][unit]['total_in'] \ - = unit_flow_rates['total_out'].coefficients(sec_time) + for port in unit_flow_rates['total_out']: + section_states[sec_time][unit]['total_in'][port] \ + = unit_flow_rates['total_out'][port].coefficients(sec_time) else: - section_states[sec_time][unit]['total_in'] \ - = unit_flow_rates['total_in'].coefficients(sec_time) + for port in unit_flow_rates['total_in']: + section_states[sec_time][unit]['total_in'][port] \ + = unit_flow_rates['total_in'][port].coefficients(sec_time) + + for port, orig_dict in unit_flow_rates.origins.items(): + for origin in orig_dict: + for origin_port, tl in orig_dict[origin].items(): + section_states[sec_time][unit]['origins'][port][origin][origin_port]\ + = tl.coefficients(sec_time) - for orig, tl in unit_flow_rates.origins.items(): - section_states[sec_time][unit]['origins'][orig] \ - = tl.coefficients(sec_time) if isinstance(self.flow_sheet[unit], Outlet): - section_states[sec_time][unit]['total_out'] \ - = unit_flow_rates['total_in'].coefficients(sec_time) + for port in unit_flow_rates['total_in']: + section_states[sec_time][unit]['total_out'][port] \ + = unit_flow_rates['total_in'][port].coefficients(sec_time) else: - section_states[sec_time][unit]['total_out'] \ - = unit_flow_rates['total_out'].coefficients(sec_time) + for port in unit_flow_rates['total_out']: + section_states[sec_time][unit]['total_out'][port] \ + = unit_flow_rates['total_out'][port].coefficients(sec_time) - for dest, tl in unit_flow_rates.destinations.items(): - section_states[sec_time][unit]['destinations'][dest] \ - = tl.coefficients(sec_time) + for port, dest_dict in unit_flow_rates.destinations.items(): + for dest in dest_dict: + for dest_port, tl in dest_dict[dest].items(): + section_states[sec_time][unit]['destinations'][port][dest][dest_port] \ + = tl.coefficients(sec_time) return Dict(section_states) @@ -552,11 +576,8 @@ def add_concentration_profile(self, unit, time, c, components=None, s=1e-6): TypeError If the specified `unit` is not an Inlet unit operation. ValueError - If the time values in `time` exceed the cycle time of the Process or - if `c` has an invalid shape. - CADETProcessError - If the number of components in `c` does not match the number of - components in the Process. + If the time values in `time` exceed the cycle time of the Process. + If `c` has an invalid shape. """ if isinstance(unit, str): @@ -566,18 +587,18 @@ def add_concentration_profile(self, unit, time, c, components=None, s=1e-6): raise TypeError('Expected Inlet') if max(time) > self.cycle_time: - raise ValueError('Inlet profile exceeds cycle time') + raise ValueError('Inlet profile exceeds cycle time.') - if components == -1: + if components is None: + if c.shape[1] != self.n_comp: + raise ValueError(f'Expected shape ({len(time), self.n_comp}) for concentration array. Got {c.shape}.') + components = self.component_system.species + elif components == -1: # Assume same profile for all components. if c.ndim > 1: - raise ValueError('Expected single concentration profile') - + raise ValueError('Expected single concentration profile.') c = np.column_stack([c]*self.n_comp) components = self.component_system.species - elif components is None and c.shape[1] != self.n_comp: - # Else, c must be given for all components. - raise CADETProcessError('Number of components does not match') if not isinstance(components, list): components = [components] @@ -687,12 +708,14 @@ def check_cstr_volume(self): for cstr in self.flow_sheet.cstrs: if cstr.flow_rate is None: continue - V_0 = cstr.V - V_in = self.flow_rate_timelines[cstr.name].total_in.integral() - V_out = self.flow_rate_timelines[cstr.name].total_out.integral() - if V_0 + V_in - V_out < 0: - flag = False - warn(f'CSTR {cstr.name} runs empty during process.') + V_0 = cstr.init_liquid_volume + unit_index = self.flow_sheet.get_unit_index(cstr) + for port in self.flow_sheet.units[unit_index].ports: + V_in = self.flow_rate_timelines[cstr.name].total_in[port].integral() + V_out = self.flow_rate_timelines[cstr.name].total_out[port].integral() + if V_0 + V_in - V_out < 0: + flag = False + warn(f'CSTR {cstr.name} runs empty on port {port} during process.') return flag diff --git a/CADETProcess/processModel/solutionRecorder.py b/CADETProcess/processModel/solutionRecorder.py index cd57a8d4..7e98b3de 100644 --- a/CADETProcess/processModel/solutionRecorder.py +++ b/CADETProcess/processModel/solutionRecorder.py @@ -363,3 +363,22 @@ class CSTRRecorder( """ pass + + +class MCTRecorder( + SolutionRecorderBase, + BaseMixin, + IOMixin, + BulkMixin): + """Recorder for TubularReactor. + + See Also + -------- + BaseMixin + IOMixin + BulkMixin + CADETProcess.processModel.MCT + + """ + + pass diff --git a/CADETProcess/processModel/unitOperation.py b/CADETProcess/processModel/unitOperation.py index 0354156d..04d8bfed 100644 --- a/CADETProcess/processModel/unitOperation.py +++ b/CADETProcess/processModel/unitOperation.py @@ -7,10 +7,10 @@ from CADETProcess.dataStructure import frozen_attributes from CADETProcess.dataStructure import Structure from CADETProcess.dataStructure import ( - Constant, UnsignedFloat, + Constant, UnsignedFloat, UnsignedInteger, String, Switch, SizedUnsignedList, - Polynomial, NdPolynomial, SizedList + Polynomial, NdPolynomial, SizedList, SizedNdArray ) from .componentSystem import ComponentSystem @@ -20,12 +20,14 @@ DiscretizationParametersBase, NoDiscretization, LRMDiscretizationFV, LRMDiscretizationDG, LRMPDiscretizationFV, LRMPDiscretizationDG, - GRMDiscretizationFV, GRMDiscretizationDG + GRMDiscretizationFV, GRMDiscretizationDG, + MCTDiscretizationFV, ) from .solutionRecorder import ( IORecorder, - TubularReactorRecorder, LRMRecorder, LRMPRecorder, GRMRecorder, CSTRRecorder + TubularReactorRecorder, LRMRecorder, LRMPRecorder, GRMRecorder, CSTRRecorder, + MCTRecorder, ) @@ -41,6 +43,7 @@ 'LumpedRateModelWithoutPores', 'LumpedRateModelWithPores', 'GeneralRateModel', + 'MCT' ] @@ -61,6 +64,8 @@ class UnitBaseClass(Structure): list of parameter names. name : String name of the unit operation. + has_ports : bool + flag if unit has ports. The default is False. binding_model : BindingBaseClass binding behavior of the unit. Defaults to NoBinding. solution_recorder : IORecorder @@ -79,23 +84,48 @@ class UnitBaseClass(Structure): _section_dependent_parameters = [] _initial_state = [] + has_ports = False supports_binding = False supports_bulk_reaction = False supports_particle_reaction = False discretization_schemes = () - def __init__(self, component_system, name, *args, **kwargs): + def __init__( + self, + component_system, + name, + *args, + binding_model=None, + bulk_reaction_model=None, + particle_reaction_model=None, + discretization=None, + solution_recorder=None, + **kwargs + ): self.name = name self.component_system = component_system - self.binding_model = NoBinding() + if binding_model is None: + binding_model = NoBinding() + self.binding_model = binding_model - self.bulk_reaction_model = NoReaction() - self.particle_reaction_model = NoReaction() + if bulk_reaction_model is None: + bulk_reaction_model = NoReaction() + self.bulk_reaction_model = bulk_reaction_model + + if particle_reaction_model is None: + particle_reaction_model = NoReaction() + self.particle_reaction_model = particle_reaction_model + + if discretization is None: + discretization = NoDiscretization() + self.discretization = discretization + + if solution_recorder is None: + solution_recorder = IORecorder() + self.solution_recorder = solution_recorder - self.discretization = NoDiscretization() - self.solution_recorder = IORecorder() super().__init__(*args, **kwargs) @@ -134,6 +164,14 @@ def discretization(self, discretization): def n_comp(self): return self.component_system.n_comp + @property + def ports(self): + return [None] + + @property + def n_ports(self): + return 1 + @property def parameters(self): """dict: Dictionary with parameter values.""" @@ -455,8 +493,6 @@ def cross_section_area(self): -------- volume cross_section_area_interstitial - cross_section_area_liquid - cross_section_area_solid """ if self.diameter is not None: @@ -466,7 +502,7 @@ def cross_section_area(self): def cross_section_area(self, cross_section_area): self.diameter = (4*cross_section_area/math.pi)**0.5 - def set_diameter_from_interstitial_velicity(self, Q, u0): + def set_diameter_from_interstitial_velocity(self, Q, u0): """Set diamter from flow rate and interstitial velocity. In literature, often only the interstitial velocity is given. @@ -499,39 +535,10 @@ def cross_section_area_interstitial(self): See Also -------- cross_section_area - cross_section_area_liquid - cross_section_area_solid - - """ - return self.total_porosity * self.cross_section_area - - @property - def cross_section_area_liquid(self): - """float: Liquid fraction of column cross section area. - - See Also - -------- - cross_section_area - cross_section_area_interstitial - cross_section_area_solid - volume """ return self.total_porosity * self.cross_section_area - @property - def cross_section_area_solid(self): - """float: Liquid fraction of column cross section area. - - See Also - -------- - cross_section_area - cross_section_area_interstitial - cross_section_area_liquid - - """ - return (1 - self.total_porosity) * self.cross_section_area - @property def volume(self): """float: Volume of the TubularReactor. @@ -557,53 +564,118 @@ def volume_interstitial(self): @property def volume_liquid(self): """float: Volume of the liquid phase.""" - return self.cross_section_area_liquid * self.length + return self.total_porosity * self.cross_section_area * self.length @property def volume_solid(self): """float: Volume of the solid phase.""" - return self.cross_section_area_solid * self.length + return (1 - self.total_porosity) * self.cross_section_area * self.length - def t0(self, flow_rate): - """Mean residence time of a (non adsorbing) volume element. + def calculate_interstitial_rt(self, flow_rate): + """Calculate mean residence time of a (non adsorbing) volume element. Parameters ---------- flow_rate : float - volumetric flow rate + Volumetric flow rate. Returns ------- t0 : float - Mean residence time + Mean residence time of packed bed. See Also -------- - u0 + calculate_interstitial_velocity + calculate_superficial_rt """ return self.volume_interstitial / flow_rate - def u0(self, flow_rate): - """Flow velocity of a (non adsorbing) volume element. + def calculate_superficial_rt(self, flow_rate): + """Calculate mean residence time of a volume element in an empty column. Parameters ---------- flow_rate : float - volumetric flow rate + Volumetric flow rate. Returns ------- - u0 : float - interstitial flow velocity + t_s : float + Mean residence time of empty column. + + See Also + -------- + calculate_superficial_velocity + calculate_interstitial_rt + + """ + return self.volume / flow_rate + + def calculate_interstitial_velocity(self, flow_rate): + """Calculate flow velocity of a (non adsorbing) volume element. + + Parameters + ---------- + flow_rate : float + Volumetric flow rate. + + Returns + ------- + interstitial_velocity : float + Interstitial flow velocity. + + See Also + -------- + calculate_interstitial_rt + calculate_superficial_velocity + + """ + return self.length/self.calculate_interstitial_rt(flow_rate) + + def calculate_superficial_velocity(self, flow_rate): + """Calculate superficial flow velocity of a volume element in an empty column. + + Parameters + ---------- + flow_rate : float + Volumetric flow rate. + + Returns + ------- + u_s : float + Superficial flow velocity. See Also -------- - t0 + calculate_superficial_rt + calculate_interstitial_velocity NTP """ - return self.length/self.t0(flow_rate) + return self.length / self.calculate_superficial_rt(flow_rate) + + def calculate_flow_rate_from_velocity(self, u0): + """Calculate volumetric flow rate from interstitial velocity. + + Parameters + ---------- + u0 : float + Interstitial flow velocity. + + Returns + ------- + Q : float + Volumetric flow rate. + + See Also + -------- + calculate_interstitial_velocity + calculate_interstitial_rt + + """ + return u0 * self.cross_section_area_interstitial def NTP(self, flow_rate): r"""Number of theoretical plates. @@ -624,7 +696,8 @@ def NTP(self, flow_rate): Number of theretical plates """ - return self.u0(flow_rate) * self.length / (2 * self.axial_dispersion) + u0 = self.calculate_interstitial_velocity(flow_rate) + return u0 * self.length / (2 * self.axial_dispersion) def set_axial_dispersion_from_NTP(self, NTP, flow_rate): r"""Set axial dispersion from number of theoretical plates (NTP). @@ -652,7 +725,8 @@ def set_axial_dispersion_from_NTP(self, NTP, flow_rate): NTP """ - self.axial_dispersion = self.u0(flow_rate) * self.length / (2 * NTP) + u0 = self.calculate_interstitial_velocity(flow_rate) + self.axial_dispersion = u0 * self.length / (2 * NTP) class TubularReactor(TubularReactorBase): @@ -678,14 +752,17 @@ class TubularReactor(TubularReactorBase): _parameters = ['c'] def __init__(self, *args, discretization_scheme='FV', **kwargs): - super().__init__(*args, **kwargs) - if discretization_scheme == 'FV': - self.discretization = LRMDiscretizationFV() + discretization = LRMDiscretizationFV() elif discretization_scheme == 'DG': - self.discretization = LRMDiscretizationDG() + discretization = LRMDiscretizationDG() - self.solution_recorder = TubularReactorRecorder() + super().__init__( + *args, + discretization=discretization, + solution_recorder=TubularReactorRecorder(), + **kwargs + ) class LumpedRateModelWithoutPores(TubularReactorBase): @@ -823,13 +900,10 @@ def cross_section_area_interstitial(self): See Also -------- cross_section_area - cross_section_area_liquid - cross_section_area_solid - """ return self.bed_porosity * self.cross_section_area - def set_diameter_from_interstitial_velicity(self, Q, u0): + def set_diameter_from_interstitial_velocity(self, Q, u0): """Set diamter from flow rate and interstitial velocity. In literature, often only the interstitial velocity is given. @@ -958,13 +1032,11 @@ def cross_section_area_interstitial(self): See Also -------- cross_section_area - cross_section_area_liquid - cross_section_area_solid """ return self.bed_porosity * self.cross_section_area - def set_diameter_from_interstitial_velicity(self, Q, u0): + def set_diameter_from_interstitial_velocity(self, Q, u0): """Set diamter from flow rate and interstitial velocity. In literature, often only the interstitial velocity is given. @@ -1039,6 +1111,10 @@ class Cstr(UnitBaseClass, SourceMixin, SinkMixin): Initial volume of the reactor. porosity : UnsignedFloat between 0 and 1. Total porosity of the Cstr. + initial_liquid_volume : UnsignedFloat above 0. + Initial liquid volume of the reactor. + const_solid_volume : UnsignedFloat above or equal 0. + Initial and constant solid volume of the reactor. flow_rate_filter: float Flow rate of pure liquid without components to reduce volume. solution_recorder : CSTRRecorder @@ -1054,9 +1130,8 @@ class Cstr(UnitBaseClass, SourceMixin, SinkMixin): supports_bulk_reaction = True supports_particle_reaction = False - porosity = UnsignedFloat(ub=1, default=1) flow_rate_filter = UnsignedFloat(default=0) - _parameters = ['porosity', 'flow_rate_filter'] + _parameters = ['const_solid_volume', 'flow_rate_filter'] _section_dependent_parameters = \ UnitBaseClass._section_dependent_parameters + \ @@ -1065,8 +1140,10 @@ class Cstr(UnitBaseClass, SourceMixin, SinkMixin): c = SizedList(size='n_comp', default=0) _q = SizedUnsignedList(size='n_bound_states', default=0) - V = UnsignedFloat() - _initial_state = ['c', 'q', 'V'] + init_liquid_volume = UnsignedFloat() + const_solid_volume = UnsignedFloat(default=0) + _V = UnsignedFloat() + _initial_state = ['c', 'q', 'init_liquid_volume'] _parameters = _parameters + _initial_state def __init__(self, *args, **kwargs): @@ -1084,28 +1161,59 @@ def required_parameters(self): required_parameters.remove('flow_rate') return required_parameters + @property + def porosity(self): + if self.const_solid_volume is None or self.init_liquid_volume is None: + return None + return self.init_liquid_volume / (self.init_liquid_volume + self.const_solid_volume) + + @porosity.setter + def porosity(self, porosity): + warnings.warn( + "Field POROSITY is only supported for backwards compatibility, but the implementation of the CSTR has " + "changed, please refer to the documentation. The POROSITY will be used to compute the " + "constant solid volume from the total volume V." + ) + if self.V is None: + raise RuntimeError("Please set the volume first before setting a porosity.") + self.const_solid_volume = self.V * (1 - porosity) + self.init_liquid_volume = self.V * porosity + + @property + def V(self): + return self._V + + @V.setter + def V(self, V): + warnings.warn( + "The field V is only supported for backwards compatibility. Please set initial_liquid_volume and " + "const_solid_volume" + ) + self.init_liquid_volume = V + self._V = V + @property def volume(self): """float: Alias for volume.""" - return self.V + return self.const_solid_volume + self.init_liquid_volume @property def volume_liquid(self): """float: Volume of the liquid phase.""" - return self.porosity * self.V + return self.init_liquid_volume @property def volume_solid(self): """float: Volume of the solid phase.""" - return (1 - self.porosity) * self.V + return self.const_solid_volume - def t0(self, flow_rate): - """Mean residence time of a (non adsorbing) volume element. + def calculate_interstitial_rt(self, flow_rate): + """Calculate mean residence time of a (non adsorbing) volume element. Parameters ---------- flow_rate : float - volumetric flow rate + Volumetric flow rate. Returns ------- @@ -1130,3 +1238,105 @@ def q(self, q): self._q = q self.parameters['q'] = q + + +class MCT(UnitBaseClass): + """Parameters for multi-channel transportmodel. + + Parameters + ---------- + length : UnsignedFloat + Length of column. + channel_cross_section_areas : List of unsinged floats. Lenght depends on nchannel. + Diameter of column. + axial_dispersion : UnsignedFloat + Dispersion rate of components in axial direction. + flow_direction : Switch + If 1: Forward flow. + If -1: Backwards flow. + c : List of unsigned floats. Length depends n_comp or n_comp*nchannel. + Initial concentration for components. + exchange_matrix : List of unsigned floats. Lenght depends on nchannel. + solution_recorder : MCTRecorder + Solution recorder for the unit operation. + n_channel : int number of channels + """ + has_ports = True + supports_bulk_reaction = True + + discretization_schemes = (MCTDiscretizationFV) + + length = UnsignedFloat() + channel_cross_section_areas = SizedList(size='nchannel') + axial_dispersion = UnsignedFloat() + flow_direction = Switch(valid=[-1, 1], default=1) + nchannel = UnsignedInteger() + + exchange_matrix = SizedNdArray(size=('nchannel', 'nchannel','n_comp')) + + _parameters = [ + 'length', + 'channel_cross_section_areas', + 'axial_dispersion', + 'flow_direction', + 'exchange_matrix', + 'nchannel' + ] + _section_dependent_parameters = \ + UnitBaseClass._section_dependent_parameters + \ + ['axial_dispersion', 'flow_direction'] + + _section_dependent_parameters = \ + UnitBaseClass._section_dependent_parameters + \ + [] + + c = SizedNdArray(size=('n_comp', 'nchannel'), default=0) + _initial_state = ['c'] + _parameters = _parameters + _initial_state + + def __init__(self, *args, nchannel, **kwargs): + discretization = MCTDiscretizationFV() + self._nchannel = nchannel + super().__init__( + *args, + discretization=discretization, + solution_recorder=MCTRecorder(), + **kwargs + ) + + @property + def nchannel(self): + return self._nchannel + + @nchannel.setter + def nchannel(self, nchannel): + self._nchannel = nchannel + + @property + def ports(self): + return [f"channel_{i}" for i in range(self.nchannel)] + + @property + def n_ports(self): + return self.nchannel + + @property + def volume(self): + """float: Combined Volumes of all channels. + + See Also + -------- + channel_cross_section_areas + + """ + return sum(self.channel_cross_section_areas) * self.length + + @property + def volume_liquid(self): + """float: Volume of the liquid phase. Equals the volume, since there is no solid phase.""" + return self.volume + + @property + def volume_solid(self): + """float: Volume of the solid phase. Equals zero, since there is no solid phase.""" + return 0 diff --git a/CADETProcess/reference.py b/CADETProcess/reference.py index 63efe3ad..6ec514da 100644 --- a/CADETProcess/reference.py +++ b/CADETProcess/reference.py @@ -18,11 +18,12 @@ import numpy as np +from CADETProcess import CADETProcessError from CADETProcess.processModel import ComponentSystem from CADETProcess.solution import SolutionBase, SolutionIO -__all__ = ['ReferenceBase', 'ReferenceIO'] +__all__ = ['ReferenceBase', 'ReferenceIO', 'FractionationReference'] class ReferenceBase(SolutionBase): @@ -37,8 +38,8 @@ class ReferenceBase(SolutionBase): pass -class ReferenceIO(SolutionIO): - """A class representing reference data of of inlet or outlet unitoperations. +class ReferenceIO(ReferenceBase, SolutionIO): + """A class representing reference data of inlet or outlet concentration profiles. Attributes ---------- @@ -109,3 +110,48 @@ def __init__( flow_rate = flow_rate * np.ones(time.shape) super().__init__(name, component_system, time, solution, flow_rate) + + +class FractionationReference(ReferenceBase): + """A class representing reference data of fractionation data. + + Attributes + ---------- + name : str + The name of the reference. + component_system : ComponentSystem + The reference component system. + time : np.ndarray + The time points for the reference. + solution : np.ndarray + The reference solution values. + + See Also + -------- + CADETProcess.reference.ReferenceBase + CADETProcess.fractionation.Fraction + """ + + dimensions = SolutionBase.dimensions + ['component_coordinates'] + + def __init__(self, name, fractions, component_system=None, *args, **kwargs): + from CADETProcess.fractionation import Fraction + + for frac in fractions: + if not isinstance(frac, Fraction): + raise TypeError("Expected Fraction.") + if frac.start is None or frac.end is None: + raise CADETProcessError("Fractionation times must be provided.") + if not frac.end > frac.start: + raise CADETProcessError("Fraction end time must be greater than start.") + + self.fractions = fractions + + time = np.array([(frac.start + frac.end)/2 for frac in self.fractions]) + solution = np.array([frac.mass / frac.volume for frac in self.fractions]) + + if component_system is None: + n_comp = solution.shape[1] + component_system = ComponentSystem(n_comp) + + super().__init__(name, component_system, time, solution) diff --git a/CADETProcess/simulationResults.py b/CADETProcess/simulationResults.py index 1306e724..fe1b5571 100644 --- a/CADETProcess/simulationResults.py +++ b/CADETProcess/simulationResults.py @@ -33,7 +33,7 @@ class SimulationResults(Structure): - """Class for storing simulation results including the solver configuration + """Class for storing simulation results including the solver configuration. Attributes ---------- @@ -142,16 +142,45 @@ def solution(self): solution = Dict() for unit, solutions in self.solution_cycles.items(): - for sol, cycles in solutions.items(): - solution[unit][sol] = copy.deepcopy(cycles[0]) - solution_complete = cycles[0].solution_original - for i in range(1, self.n_cycles): - solution_complete = np.vstack(( - solution_complete, cycles[i].solution_original[1:] - )) - solution[unit][sol].time_original = time_complete - solution[unit][sol].solution_original = solution_complete - solution[unit][sol].reset() + + for sol, ports_cycles in solutions.items(): + if isinstance(ports_cycles, Dict): + ports = ports_cycles + for port, cycles in ports.items(): + solution[unit][sol][port] = copy.deepcopy(cycles[0]) + solution_complete = cycles[0].solution_original + if solution_complete.ndim > 1: + for i in range(1, self.n_cycles): + solution_complete = np.vstack(( + solution_complete, cycles[i].solution_original[1:] + )) + else: + for i in range(1, self.n_cycles): + solution_complete = np.hstack(( + solution_complete, cycles[i].solution_original[1:] + )) + + solution[unit][sol][port].time_original = time_complete + solution[unit][sol][port].solution_original = solution_complete + solution[unit][sol][port].reset() + else: + cycles = ports_cycles + solution[unit][sol] = copy.deepcopy(cycles[0]) + solution_complete = cycles[0].solution_original + if solution_complete.ndim > 1: + for i in range(1, self.n_cycles): + solution_complete = np.vstack(( + solution_complete, cycles[i].solution_original[1:] + )) + else: + for i in range(1, self.n_cycles): + solution_complete = np.hstack(( + solution_complete, cycles[i].solution_original[1:] + )) + + solution[unit][sol].time_original = time_complete + solution[unit][sol].solution_original = solution_complete + solution[unit][sol].reset() self._solution = solution @@ -166,18 +195,36 @@ def sensitivity(self): time_complete = self.time_complete sensitivity = Dict() - for unit, sensitivities in self.sensitivity_cycles.items(): - for sens_name, sensitivities in sensitivities.items(): - for sens, cycles in sensitivities.items(): - sensitivity[unit][sens_name][sens] = copy.deepcopy(cycles[0]) - sensitivity_complete = cycles[0].solution_original - for i in range(1, self.n_cycles): - sensitivity_complete = np.vstack(( - sensitivity_complete, cycles[i].solution_original[1:] - )) - sensitivity[unit][sens_name][sens].time_original = time_complete - sensitivity[unit][sens_name][sens].solution_original = sensitivity_complete - sensitivity[unit][sens_name][sens].reset() + for sens_name, sensitivities in self.sensitivity_cycles.items(): + + for unit, sensitivities in sensitivities.items(): + + for flow, ports_cycles in sensitivities.items(): + if isinstance(ports_cycles, Dict): + ports = ports_cycles + for port, cycles in ports.items(): + sensitivity[sens_name][unit][flow][port] = copy.deepcopy( + cycles[0]) + sensitivity_complete = cycles[0].solution_original + for i in range(1, self.n_cycles): + sensitivity_complete = np.vstack(( + sensitivity_complete, cycles[i].solution_original[1:] + )) + sensitivity[sens_name][unit][flow][port].time_original = time_complete + sensitivity[sens_name][unit][flow][port].solution_original = sensitivity_complete + sensitivity[sens_name][unit][flow][port].reset() + + else: + cycles = ports_cycles + sensitivity[sens_name][unit][flow] = copy.deepcopy(cycles[0]) + sensitivity_complete = cycles[0].solution_original + for i in range(1, self.n_cycles): + sensitivity_complete = np.vstack(( + sensitivity_complete, cycles[i].solution_original[1:] + )) + sensitivity[sens_name][unit][flow].time_original = time_complete + sensitivity[sens_name][unit][flow].solution_original = sensitivity_complete + sensitivity[sens_name][unit][flow].reset() self._sensitivity = sensitivity diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index 1bcd2782..044f90b0 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -1,3 +1,4 @@ +from CADETProcess.dataStructure import Structure, ParameterWrapper from collections import defaultdict from functools import wraps import os @@ -9,6 +10,7 @@ import time import tempfile import warnings +import re from addict import Dict import numpy as np @@ -92,13 +94,8 @@ class Cadet(SimulatorBase): def __init__(self, install_path=None, temp_dir=None, *args, **kwargs): super().__init__(*args, **kwargs) - self.cadet_root = None - self.cadet_cli_path = None - self.cadet_create_lwe_path = None - self.cadet_dll_path = None - if install_path is None: - self.autodetect_cadet() + self.install_path = CadetAPI.autodetect_cadet() else: self.install_path = install_path @@ -142,152 +139,6 @@ def wrapper(self, process, *args, **kwargs): return wrapper - def autodetect_cadet(self): - """ - Autodetect installation CADET based on operating system and API usage. - - Returns - ------- - cadet_root : Path - Installation path of the CADET program. - """ - executable = 'cadet-cli' - if platform.system() == 'Windows': - executable += '.exe' - - # Searching for the executable in system path - path = shutil.which(executable) - - if path is None: - raise FileNotFoundError( - "Could not autodetect CADET installation. Please provide path." - ) - else: - self.logger.info(f"Found CADET executable at {path}") - - cli_path = Path(path) - - cadet_root = None - if cli_path is not None: - cadet_root = cli_path.parent.parent - self.install_path = cadet_root - - return cadet_root - - @property - def cadet_path(self): - if self.use_dll and self.found_dll: - return self.cadet_cll_path - return self.cadet_cli_path - - @property - def found_dll(self): - flag = False - if self.cadet_dll_path is not None: - flag = True - return flag - - @property - def install_path(self): - """str: Path to the installation of CADET. - - This can either be the root directory of the installation or the path to the - executable file 'cadet-cli'. If a file path is provided, the root directory will - be inferred. - - Raises - ------ - FileNotFoundError - If CADET cannot be found at the specified path. - - Warnings - -------- - If the specified install_path is not the root of the CADET installation, it will - be inferred from the file path. - - See Also - -------- - check_cadet - """ - return self._install_path - - @install_path.setter - def install_path(self, install_path): - """ - Set the installation path of CADET. - - Parameters - ---------- - install_path : str or Path - Path to the root of the CADET installation. - It should either be the root directory of the installation or the path - to the executable file 'cadet-cli'. - If a file path is provided, the root directory will be inferred. - """ - if install_path is None: - self._install_path = None - self.cadet_cli_path = None - self.cadet_dll_path = None - self.cadet_create_lwe_path = None - - return - - install_path = Path(install_path) - - if install_path.is_file(): - cadet_root = install_path.parent.parent - warnings.warn( - "The specified install_path is not the root of the CADET installation. " - "It has been inferred from the file path." - ) - else: - cadet_root = install_path - - self._install_path = cadet_root - - cli_executable = 'cadet-cli' - lwe_executable = 'createLWE' - - if platform.system() == 'Windows': - cli_executable += '.exe' - lwe_executable += '.exe' - - cadet_cli_path = cadet_root / 'bin' / cli_executable - if cadet_cli_path.is_file(): - self.cadet_cli_path = cadet_cli_path - else: - raise FileNotFoundError( - "CADET could not be found. Please check the path" - ) - - cadet_create_lwe_path = cadet_root / 'bin' / lwe_executable - if cadet_create_lwe_path.is_file(): - self.cadet_create_lwe_path = cadet_create_lwe_path.as_posix() - - if platform.system() == 'Windows': - dll_path = cadet_root / 'bin' / 'cadet.dll' - dll_debug_path = cadet_root / 'bin' / 'cadet_d.dll' - else: - dll_path = cadet_root / 'lib' / 'lib_cadet.so' - dll_debug_path = cadet_root / 'lib' / 'lib_cadet_d.so' - - # Look for debug dll if dll is not found. - if not dll_path.is_file() and dll_debug_path.is_file(): - dll_path = dll_debug_path - - # Look for debug dll if dll is not found. - if dll_path.is_file(): - self.cadet_dll_path = dll_path.as_posix() - - if platform.system() != 'Windows': - try: - cadet_lib_path = cadet_root / 'lib' - if cadet_lib_path.as_posix() not in os.environ['LD_LIBRARY_PATH']: - os.environ['LD_LIBRARY_PATH'] += \ - os.pathsep + cadet_lib_path.as_posix() - except KeyError: - os.environ['LD_LIBRARY_PATH'] = cadet_lib_path.as_posix() - def check_cadet(self): """ Check if CADET installation can run a basic LWE example. @@ -311,70 +162,21 @@ def check_cadet(self): """ lwe_hdf5_path = Path(self.temp_dir) / 'LWE.h5' - cadet_model = self.create_lwe(lwe_hdf5_path) + cadet_model = self.get_new_cadet_instance() + + cadet_model.create_lwe(lwe_hdf5_path) - data = cadet_model.run() + cadet_model.run() os.remove(lwe_hdf5_path) - if data.returncode == 0: - flag = True - print("Test simulation completed successfully") - else: - flag = False - raise CADETProcessError(f"Simulation failed with {data}") + print("Test simulation completed successfully") - return flag + return True def get_tempfile_name(self): f = next(tempfile._get_candidate_names()) return self.temp_dir / f'{f}.h5' - def create_lwe(self, file_path=None): - """Create basic LWE example. - - Parameters - ---------- - file_path : Path, optional - Path to store HDF5 file. If None, temporary file will be created and - deleted after simulation. - - Returns - ------- - - """ - if file_path is None: - file_name = self.get_tempfile_name().as_posix() - cwd = self.temp_dir.as_posix() - else: - file_path = Path(file_path).absolute() - file_name = file_path.name - cwd = file_path.parent.as_posix() - - ret = subprocess.run( - [self.cadet_create_lwe_path, '-o', file_name], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd=cwd - ) - if ret.returncode != 0: - if ret.stdout: - print('Output', ret.stdout.decode('utf-8')) - if ret.stderr: - print('Errors', ret.stderr.decode('utf-8')) - raise CADETProcessError( - "Failure: Creation of test simulation ran into problems" - ) - - cadet_model = self.get_new_cadet_instance() - - cadet_model.filename = file_path.as_posix() - - cadet_model.load() - - if file_path is None: - os.remove(file_path) - - return cadet_model @locks_process def run(self, process, cadet=None, file_path=None): @@ -419,7 +221,7 @@ def run(self, process, cadet=None, file_path=None): cadet.root = self.get_process_config(process) - if cadet.is_file: + if not self.use_dll: if file_path is None: cadet.filename = self.get_tempfile_name() else: @@ -433,16 +235,16 @@ def run(self, process, cadet=None, file_path=None): except TimeoutExpired: raise CADETProcessError('Simulator timed out') from None finally: - if file_path is None: + if not self.use_dll and file_path is None: os.remove(cadet.filename) - if return_information.returncode != 0: + if return_information.return_code != 0: self.logger.error( f'Simulation of {process.name} ' f'with parameters {process.config} failed.' ) raise CADETProcessError( - f'CADET Error: Simulation failed with {return_information.stderr}' + f'CADET Error: Simulation failed with {return_information.error_message}' ) from None try: @@ -456,12 +258,47 @@ def run(self, process, cadet=None, file_path=None): return results + def get_cadet_version(self) -> tuple[str, str]: + """ + Get version and branch name of the currently instanced CADET build. + + Returns + ------- + tuple[str, str] + The CADET version as x.x.x and the branch name. + + Raises + ------ + ValueError + If version and branch name cannot be found in the output string. + RuntimeError + If any unhandled event during running the subprocess occurs. + """ + try: + result = subprocess.run( + [self.get_new_cadet_instance().cadet_cli_path, '--version'], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + version_output = result.stdout.strip() + + version_match = re.search( + r'cadet-cli version ([\d.]+) \(([^)]+)\)', version_output + ) + + if version_match: + cadet_version = version_match.group(1) + branch_name = version_match.group(2) + return cadet_version, branch_name + else: + raise ValueError("CADET version or branch name missing from output.") + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Command execution failed: {e}") + def get_new_cadet_instance(self): - cadet = CadetAPI() - # Because the initialization in __init__ isn't guaranteed to be called in multiprocessing - # situations, ensure that the cadet_path has actually been set. - if not hasattr(cadet, "cadet_path"): - cadet.cadet_path = self.cadet_path + cadet = CadetAPI(install_path=self.install_path, use_dll=self.use_dll) return cadet def save_to_h5(self, process, file_path): @@ -485,6 +322,10 @@ def load_from_h5(self, file_path): return cadet + @wraps(CadetAPI.create_lwe) + def create_lwe(self, *args, **kwargs): + return self.get_new_cadet_instance().create_lwe(*args, **kwargs) + @locks_process def get_process_config(self, process): """Create the CADET config. @@ -537,7 +378,7 @@ def get_simulation_results( Cadet object with simulation results. time_elapsed : float Time of simulation. - return_information: str + return_information: ReturnInformation CADET-cli return information. Returns @@ -556,177 +397,243 @@ def get_simulation_results( exit_flag = None exit_message = None else: - exit_flag = return_information.returncode - exit_message = return_information.stderr.decode() + exit_flag = return_information.return_code + exit_message = return_information.error_message try: solution = Dict() for unit in process.flow_sheet.units: + solution[unit.name] = defaultdict(list) + + port_flag = unit.has_ports + + if port_flag: + solution[unit.name]['inlet'] = defaultdict(list) + solution[unit.name]['outlet'] = defaultdict(list) + unit_index = self.get_unit_index(process, unit) unit_solution = cadet.root.output.solution[unit_index] + unit_coordinates = \ cadet.root.output.coordinates[unit_index].copy() particle_coordinates = \ unit_coordinates.pop('particle_coordinates_000', None) - flow_in = process.flow_rate_timelines[unit.name].total_in - flow_out = process.flow_rate_timelines[unit.name].total_out + for port in unit.ports: - for cycle in range(self.n_cycles): - start = cycle * len(time) - end = (cycle + 1) * len(time) + port_index = self.get_port_index(process.flow_sheet, unit, port) - if 'solution_inlet' in unit_solution.keys(): - sol_inlet = unit_solution.solution_inlet[start:end, :] - solution[unit.name]['inlet'].append( - SolutionIO( - unit.name, - unit.component_system, time, sol_inlet, - flow_in - ) - ) - - if 'solution_outlet' in unit_solution.keys(): - sol_outlet = unit_solution.solution_outlet[start:end, :] - solution[unit.name]['outlet'].append( - SolutionIO( - unit.name, - unit.component_system, time, sol_outlet, - flow_out - ) - ) - - if 'solution_bulk' in unit_solution.keys(): - sol_bulk = unit_solution.solution_bulk[start:end, :] - solution[unit.name]['bulk'].append( - SolutionBulk( - unit.name, - unit.component_system, time, sol_bulk, - **unit_coordinates - ) - ) - - if 'solution_particle' in unit_solution.keys(): - sol_particle = unit_solution.solution_particle[start:end, :] - solution[unit.name]['particle'].append( - SolutionParticle( - unit.name, - unit.component_system, time, sol_particle, - **unit_coordinates, - particle_coordinates=particle_coordinates - ) - ) - - if 'solution_solid' in unit_solution.keys(): - sol_solid = unit_solution.solution_solid[start:end, :] - solution[unit.name]['solid'].append( - SolutionSolid( - unit.name, - unit.component_system, - unit.binding_model.bound_states, - time, sol_solid, - **unit_coordinates, - particle_coordinates=particle_coordinates - ) - ) - - if 'solution_volume' in unit_solution.keys(): - sol_volume = unit_solution.solution_volume[start:end, :] - solution[unit.name]['volume'].append( - SolutionVolume( - unit.name, - unit.component_system, - time, - sol_volume - ) - ) - - solution = Dict(solution) - - sensitivity = Dict() - for i, sens in enumerate(process.parameter_sensitivities): - sens_index = f'param_{i:03d}' - for unit in process.flow_sheet.units: - sensitivity[sens.name][unit.name] = defaultdict(list) - unit_index = self.get_unit_index(process, unit) - unit_sensitivity = cadet.root.output.sensitivity[sens_index][unit_index] - unit_coordinates = \ - cadet.root.output.coordinates[unit_index].copy() - particle_coordinates = \ - unit_coordinates.pop('particle_coordinates_000', None) - - flow_in = process.flow_rate_timelines[unit.name].total_in - flow_out = process.flow_rate_timelines[unit.name].total_out + flow_in = process.flow_rate_timelines[unit.name].total_in[port] + flow_out = process.flow_rate_timelines[unit.name].total_out[port] + start = 0 for cycle in range(self.n_cycles): - start = cycle * len(time) - end = (cycle + 1) * len(time) - - if 'sens_inlet' in unit_sensitivity.keys(): - sens_inlet = unit_sensitivity.sens_inlet[start:end, :] - sensitivity[sens.name][unit.name]['inlet'].append( - SolutionIO( - unit.name, - unit.component_system, time, sens_inlet, - flow_in + end = start + len(time) + + if f'solution_inlet_port_{port_index:03d}' in unit_solution.keys(): + sol_inlet = unit_solution[f'solution_inlet_port_{port_index:03d}'][start:end,] + if port_flag: + solution[unit.name]['inlet'][port].append( + SolutionIO( + unit.name, + unit.component_system, time, sol_inlet, + flow_in + ) + ) + else: + solution[unit.name]['inlet'].append( + SolutionIO( + unit.name, + unit.component_system, time, sol_inlet, + flow_in + ) ) - ) - if 'sens_outlet' in unit_sensitivity.keys(): - sens_outlet = unit_sensitivity.sens_outlet[start:end, :] - sensitivity[sens.name][unit.name]['outlet'].append( - SolutionIO( - unit.name, - unit.component_system, time, sens_outlet, - flow_out + if f'solution_outlet_port_{port_index:03d}' in unit_solution.keys(): + sol_outlet = unit_solution[f'solution_outlet_port_{port_index:03d}'][start:end, :] + if port_flag: + solution[unit.name]['outlet'][port].append( + SolutionIO( + unit.name, + unit.component_system, time, sol_outlet, + flow_out + ) + ) + else: + solution[unit.name]['outlet'].append( + SolutionIO( + unit.name, + unit.component_system, time, sol_outlet, + flow_out + ) ) - ) - if 'sens_bulk' in unit_sensitivity.keys(): - sens_bulk = unit_sensitivity.sens_bulk[start:end, :] - sensitivity[sens.name][unit.name]['bulk'].append( + if 'solution_bulk' in unit_solution.keys(): + sol_bulk = unit_solution.solution_bulk[start:end, :] + solution[unit.name]['bulk'].append( SolutionBulk( unit.name, - unit.component_system, time, sens_bulk, + unit.component_system, time, sol_bulk, **unit_coordinates ) ) - if 'sens_particle' in unit_sensitivity.keys(): - sens_particle = unit_sensitivity.sens_particle[start:end, :] - sensitivity[sens.name][unit.name]['particle'].append( + if 'solution_particle' in unit_solution.keys(): + sol_particle = unit_solution.solution_particle[start:end, :] + solution[unit.name]['particle'].append( SolutionParticle( unit.name, - unit.component_system, time, sens_particle, + unit.component_system, time, sol_particle, **unit_coordinates, particle_coordinates=particle_coordinates ) ) - if 'sens_solid' in unit_sensitivity.keys(): - sens_solid = unit_sensitivity.sens_solid[start:end, :] - sensitivity[sens.name][unit.name]['solid'].append( + if 'solution_solid' in unit_solution.keys(): + sol_solid = unit_solution.solution_solid[start:end, :] + solution[unit.name]['solid'].append( SolutionSolid( unit.name, unit.component_system, unit.binding_model.bound_states, - time, sens_solid, + time, sol_solid, **unit_coordinates, particle_coordinates=particle_coordinates ) ) - if 'sens_volume' in unit_sensitivity.keys(): - sens_volume = unit_sensitivity.sens_volume[start:end, :] - sensitivity[sens.name][unit.name]['volume'].append( + if 'solution_volume' in unit_solution.keys(): + sol_volume = unit_solution.solution_volume[start:end] + solution[unit.name]['volume'].append( SolutionVolume( unit.name, unit.component_system, time, - sens_volume + sol_volume ) ) + start = end - 1 + + solution = Dict(solution) + + sensitivity = Dict() + for i, sens in enumerate(process.parameter_sensitivities): + sens_index = f'param_{i:03d}' + + for unit in process.flow_sheet.units: + + sensitivity[sens.name][unit.name] = defaultdict(list) + + port_flag = unit.has_ports + + if port_flag: + sensitivity[sens.name][unit.name]['inlet'] = defaultdict(list) + sensitivity[sens.name][unit.name]['outlet'] = defaultdict(list) + + unit_index = self.get_unit_index(process, unit) + unit_sensitivity = cadet.root.output.sensitivity[sens_index][unit_index] + unit_coordinates = \ + cadet.root.output.coordinates[unit_index].copy() + particle_coordinates = \ + unit_coordinates.pop('particle_coordinates_000', None) + + for port in unit.ports: + + port_index = self.get_port_index(process.flow_sheet, unit, port) + + flow_in = process.flow_rate_timelines[unit.name].total_in[port] + flow_out = process.flow_rate_timelines[unit.name].total_out[port] + + start = 0 + for cycle in range(self.n_cycles): + end = start + len(time) + + if f'sens_inlet_port_{port_index:03d}' in unit_sensitivity.keys(): + sens_inlet = unit_sensitivity[f'sens_inlet_port_{port_index:03d}'][start:end, :] + if port_flag: + sensitivity[sens.name][unit.name]['inlet'][port].append( + SolutionIO( + unit.name, + unit.component_system, time, sens_inlet, + flow_in + ) + ) + + else: + sensitivity[sens.name][unit.name]['inlet'].append( + SolutionIO( + unit.name, + unit.component_system, time, sens_inlet, + flow_in + ) + ) + + if f'sens_outlet_port_{port_index:03d}' in unit_sensitivity.keys(): + sens_outlet = unit_sensitivity[f'sens_outlet_port_{port_index:03d}'][start:end, :] + if port_flag: + sensitivity[sens.name][unit.name]['outlet'][port].append( + SolutionIO( + unit.name, + unit.component_system, time, sens_outlet, + flow_out + ) + ) + else: + sensitivity[sens.name][unit.name]['outlet'].append( + SolutionIO( + unit.name, + unit.component_system, time, sens_outlet, + flow_out + ) + ) + + if 'sens_bulk' in unit_sensitivity.keys(): + sens_bulk = unit_sensitivity.sens_bulk[start:end, :] + sensitivity[sens.name][unit.name]['bulk'].append( + SolutionBulk( + unit.name, + unit.component_system, time, sens_bulk, + **unit_coordinates + ) + ) + + if 'sens_particle' in unit_sensitivity.keys(): + sens_particle = unit_sensitivity.sens_particle[start:end, :] + sensitivity[sens.name][unit.name]['particle'].append( + SolutionParticle( + unit.name, + unit.component_system, time, sens_particle, + **unit_coordinates, + particle_coordinates=particle_coordinates + ) + ) + + if 'sens_solid' in unit_sensitivity.keys(): + sens_solid = unit_sensitivity.sens_solid[start:end, :] + sensitivity[sens.name][unit.name]['solid'].append( + SolutionSolid( + unit.name, + unit.component_system, + unit.binding_model.bound_states, + time, sens_solid, + **unit_coordinates, + particle_coordinates=particle_coordinates + ) + ) + + if 'sens_volume' in unit_sensitivity.keys(): + sens_volume = unit_sensitivity.sens_volume[start:end, :] + sensitivity[sens.name][unit.name]['volume'].append( + SolutionVolume( + unit.name, + unit.component_system, + time, + sens_volume + ) + ) + + start = end - 1 sensitivity = Dict(sensitivity) @@ -795,6 +702,8 @@ def get_model_connections(self, process): else: model_connections['CONNECTIONS_INCLUDE_DYNAMIC_FLOW'] = 1 + model_connections['CONNECTIONS_INCLUDE_PORTS'] = 1 + index = 0 section_states = process.flow_rate_section_states @@ -837,21 +746,31 @@ def cadet_connections(self, flow_rates, flow_sheet): for origin, unit_flow_rates in flow_rates.items(): origin = flow_sheet[origin] origin_index = flow_sheet.get_unit_index(origin) - for dest, flow_rate in unit_flow_rates.destinations.items(): - destination = flow_sheet[dest] - destination_index = flow_sheet.get_unit_index(destination) - if np.any(flow_rate): - table[enum] = [] - table[enum].append(int(origin_index)) - table[enum].append(int(destination_index)) - table[enum].append(-1) - table[enum].append(-1) - Q = flow_rate.tolist() - if self._force_constant_flow_rate: - table[enum] += [Q[0]] - else: - table[enum] += Q - enum += 1 + for origin_port, dest_dict in unit_flow_rates.destinations.items(): + for dest in dest_dict: + for dest_port, flow_rate in dest_dict[dest].items(): + destination = flow_sheet[dest] + destination_index = flow_sheet.get_unit_index(destination) + if np.any(flow_rate): + + origin_port_red = flow_sheet.get_port_index( + origin, origin_port) + dest_port_red = flow_sheet.get_port_index( + destination, dest_port) + + table[enum] = [] + table[enum].append(int(origin_index)) + table[enum].append(int(destination_index)) + table[enum].append(int(origin_port_red)) + table[enum].append(int(dest_port_red)) + table[enum].append(-1) + table[enum].append(-1) + Q = flow_rate.tolist() + if self._force_constant_flow_rate: + table[enum] += [Q[0]] + else: + table[enum] += Q + enum += 1 ls = [] for connection in table.values(): @@ -878,6 +797,24 @@ def get_unit_index(self, process, unit): index = process.flow_sheet.get_unit_index(unit) return f'unit_{index:03d}' + def get_port_index(self, flow_sheet, unit, port): + """Helper function for getting port index in CADET format xxx. + + Parameters + ---------- + port : string + Indexed port + unit : UnitOperation + port of unit + Returns + ------- + port_index : index + Return the port_index in CADET format xxx + + """ + + return flow_sheet.get_port_index(unit, port) + def get_model_units(self, process): """Config branches for all units /input/model/unit_000 ... unit_xxx. @@ -934,8 +871,6 @@ def get_unit_config(self, unit): if not isinstance(unit.discretization, NoDiscretization): unit_config['discretization'] = unit.discretization.parameters - if isinstance(unit.discretization, DGMixin): - unit_config['UNIT_TYPE'] += '_DG' if isinstance(unit, Cstr) \ and not isinstance(unit.binding_model, NoBinding): @@ -1209,7 +1144,6 @@ def __str__(self): return 'CADET' -from CADETProcess.dataStructure import Structure, ParameterWrapper class ModelSolverParameters(Structure): """Converter for model solver parameters from CADETProcess to CADET. @@ -1323,7 +1257,7 @@ class ModelSolverParameters(Structure): 'COL_LENGTH': 'length', 'CROSS_SECTION_AREA': 'cross_section_area', 'VELOCITY': 'flow_direction', - }, + }, 'fixed': { 'TOTAL_POROSITY': 1, }, @@ -1332,11 +1266,24 @@ class ModelSolverParameters(Structure): 'name': 'CSTR', 'parameters': { 'NCOMP': 'n_comp', - 'INIT_VOLUME': 'V', 'INIT_C': 'c', 'INIT_Q': 'q', - 'POROSITY': 'porosity', 'FLOWRATE_FILTER': 'flow_rate_filter', + 'CONST_SOLID_VOLUME': 'const_solid_volume', + 'INIT_LIQUID_VOLUME': 'init_liquid_volume', + }, + }, + 'MCT': { + 'name': 'MULTI_CHANNEL_TRANSPORT', + 'parameters': { + 'NCOMP': 'n_comp', + 'INIT_C': 'c', + 'COL_DISPERSION': 'axial_dispersion', + 'COL_LENGTH': 'length', + 'NCHANNEL': 'nchannel', + 'CHANNEL_CROSS_SECTION_AREAS': 'channel_cross_section_areas', + 'EXCHANGE_MATRIX': 'exchange_matrix', + 'VELOCITY': 'flow_direction', }, }, 'Inlet': { @@ -1506,7 +1453,8 @@ class UnitParameters(ParameterWrapper): 'MPM_KD': 'desorption_rate', 'MPM_QMAX': 'capacity', 'MPM_BETA': 'ion_exchange_characteristic', - 'MPM_GAMMA': 'hydrophobicity' + 'MPM_GAMMA': 'hydrophobicity', + 'MPM_LINEAR_THRESHOLD': 'linear_threshold', }, }, 'ExtendedMobilePhaseModulator': { @@ -1644,6 +1592,31 @@ class UnitParameters(ParameterWrapper): 'HICWHS_BETA1': 'beta_1', }, }, + 'MultiComponentColloidal': { + 'name': 'MULTI_COMPONENT_COLLOIDAL', + 'parameters': { + 'IS_KINETIC': 'is_kinetic', + 'COL_PHI': 'phase_ratio', + 'COL_KAPPA_EXP': 'kappa_exponential', + 'COL_KAPPA_FACT': 'kappa_factor', + 'COL_KAPPA_CONST': 'kappa_constant', + 'COL_CORDNUM': 'coordination_number', + 'COL_LOGKEQ_PH_EXP': 'logkeq_ph_exponent', + 'COL_LOGKEQ_SALT_POWEXP': 'logkeq_power_exponent', + 'COL_LOGKEQ_SALT_POWFACT': 'logkeq_power_factor', + 'COL_LOGKEQ_SALT_EXPFACT': 'logkeq_exponent_factor', + 'COL_LOGKEQ_SALT_EXPARGMULT': 'logkeq_exponent_multiplier', + 'COL_BPP_PH_EXP': 'bpp_ph_exponent', + 'COL_BPP_SALT_POWEXP': 'bpp_power_exponent', + 'COL_BPP_SALT_POWFACT': 'bpp_power_factor', + 'COL_BPP_SALT_EXPFACT': 'bpp_exponent_factor', + 'COL_BPP_SALT_EXPARGMULT': 'bpp_exponent_multiplier', + 'COL_PROTEIN_RADIUS': 'protein_radius', + 'COL_KKIN': 'kinetic_rate_constant', + 'COL_LINEAR_THRESHOLD': 'linear_threshold', + 'COL_USE_PH': 'use_ph', + }, + }, } inv_adsorption_parameters_map = { @@ -1687,7 +1660,7 @@ class AdsorptionParameters(ParameterWrapper): 'mal_exponents_bulk_bwd': 'exponents_bwd', 'mal_kfwd_bulk': 'k_fwd', 'mal_kbwd_bulk': 'k_bwd', - } + } }, 'MassActionLawParticle': { 'name': 'MASS_ACTION_LAW', @@ -1867,11 +1840,12 @@ class ReturnParameters(Structure): write_solution_last = Bool(default=True) write_sens_last = Bool(default=True) split_components_data = Bool(default=False) - split_ports_data = Bool(default=False) + split_ports_data = Bool(default=True) + single_as_multi_port = Bool(default=True) _parameters = [ 'write_solution_times', 'write_solution_last', 'write_sens_last', - 'split_components_data', 'split_ports_data' + 'split_components_data', 'split_ports_data', 'single_as_multi_port', ] diff --git a/CADETProcess/simulator/simulator.py b/CADETProcess/simulator/simulator.py index 1aea1341..f31f5500 100644 --- a/CADETProcess/simulator/simulator.py +++ b/CADETProcess/simulator/simulator.py @@ -136,10 +136,12 @@ def get_solution_time_complete(self, process): """ time = self.get_solution_time(process) - solution_times = np.array([]) - for i in range(self.n_cycles): + + solution_times = time + + for i in range(1, self.n_cycles): solution_times = np.append( - solution_times, (i)*time[-1] + time + solution_times, (i)*time[-1] + time[1:] ) solution_times = np.round(solution_times, self.sig_fig) diff --git a/CADETProcess/solution.py b/CADETProcess/solution.py index 90ee8add..48261b40 100755 --- a/CADETProcess/solution.py +++ b/CADETProcess/solution.py @@ -30,6 +30,7 @@ import copy import numpy as np +from matplotlib.axes import Axes import matplotlib.pyplot as plt from scipy.interpolate import PchipInterpolator from scipy import integrate @@ -445,11 +446,42 @@ def integral(self, start=None, end=None): Mass of all components in the fraction """ + if start is None: + start = self.time[0] + if end is None: end = self.cycle_time return self.solution_interpolated.integral(start, end) + def create_fraction(self, start=None, end=None): + """Create fraction in interval [start, end]. + + Parameters + ---------- + start : float + Start time of the fraction + + end: float + End time of the fraction + + Returns + ------- + fraction : Fraction + Fraction + + """ + if start is None: + start = self.time[0] + + if end is None: + end = self.cycle_time + + from CADETProcess.fractionation import Fraction + mass = self.fraction_mass(start, end) + volume = self.fraction_volume(start, end) + return Fraction(mass, volume, start, end) + def fraction_mass(self, start=None, end=None): """Component mass in a fraction interval @@ -467,6 +499,9 @@ def fraction_mass(self, start=None, end=None): Mass of all components in the fraction """ + if start is None: + start = self.time[0] + if end is None: end = self.cycle_time @@ -490,7 +525,7 @@ def dm_dt(t, flow_rate, solution): return mass def fraction_volume(self, start=None, end=None): - """Volume of a fraction interval + """Volume of a fraction interval. Parameters ---------- @@ -506,6 +541,9 @@ def fraction_volume(self, start=None, end=None): Volume of the fraction """ + if start is None: + start = self.time[0] + if end is None: end = self.cycle_time @@ -514,32 +552,38 @@ def fraction_volume(self, start=None, end=None): @plotting.create_and_save_figure def plot( self, - start=None, - end=None, - components=None, - layout=None, - y_max=None, - ax=None, + start: float | None = None, + end: float | None = None, + components: list[str] | None = None, + layout: plotting.Layout | None = None, + y_max: float | None = None, + x_axis_in_minutes: bool = True, + ax: Axes | None = None, *args, **kwargs, - ): + ) -> Axes: """Plot the entire time_signal for each component. Parameters ---------- start : float, optional - Start time for plotting. The default is 0. + Start time for plotting in seconds. If None is provided, the first data + point will be used as the start time. The default is None. end : float, optional - End time for plotting. - components : list, optional. + End time for plotting in seconds. If None is provided, the last data point + will be used as the end time. The default is None. + components : list of str, optional List of components to be plotted. If None, all components are plotted. - layout : plotting.Layout - Plot layout options. + The default is None. + layout : plotting.Layout, optional + Plot layout options. The default is None. y_max : float, optional - Maximum value of y axis. - If None, value is automatically deferred from solution. - ax : Axes - Axes to plot on. + Maximum value of the y-axis. If None, the value is automatically + determined from the data. The default is None. + x_axis_in_minutes : bool, optional + If True, the x-axis will be plotted using minutes. The default is True. + ax : Axes, optional + Axes to plot on. If None, a new figure is created. Returns ------- @@ -561,16 +605,20 @@ def plot( coordinates={'time': [start, end]} ) - x = solution.time / 60 + x = solution.time + if x_axis_in_minutes: + x = x / 60 + if start is not None: + start = start / 60 + if end is not None: + end = end / 60 if layout is None: layout = plotting.Layout() - layout.x_label = '$time~/~min$' + layout.x_label = '$time~/~s$' + if x_axis_in_minutes: + layout.x_label = '$time~/~min$' layout.y_label = '$c~/~mM$' - if start is not None: - start /= 60 - if end is not None: - end /= 60 layout.x_lim = (start, end) if y_max is not None: layout.y_lim = (None, y_max) @@ -582,24 +630,29 @@ def plot( @plotting.create_and_save_figure def plot_purity( self, - start=None, - end=None, - components=None, - layout=None, - y_max=None, - plot_components_purity=True, - plot_species_purity=False, - alpha=1, hide_labels=False, - show_legend=True, - ax=None): + start: float | None = None, + end: float | None = None, + components: list[str] | None = None, + layout: plotting.Layout | None = None, + y_max: float | None = None, + x_axis_in_minutes: bool = True, + plot_components_purity: bool = True, + plot_species_purity: bool = False, + alpha: float = 1, + hide_labels: bool = False, + show_legend: bool = True, + ax: Axes | None = None, + ) -> Axes: """Plot local purity for each component of the concentration profile. Parameters ---------- start : float, optional - Start time for plotting. The default is 0. + Start time for plotting in seconds. If None is provided, the first data + point will be used as the start time. The default is None. end : float, optional - End time for plotting. + End time for plotting in seconds. If None is provided, the last data point + will be used as the end time. The default is None. components : list, optional. List of components to be plotted. If None, all components are plotted. Note that if components are excluded, they will also not be considered in @@ -609,6 +662,8 @@ def plot_purity( y_max : float, optional Maximum value of y axis. If None, value is automatically deferred from solution. + x_axis_in_minutes : bool, optional + If True, the x-axis will be plotted using minutes. The default is True. plot_components_purity : bool, optional If True, plot purity of total component concentration. The default is True. plot_species_purity : bool, optional @@ -651,11 +706,19 @@ def plot_purity( "Purity undefined for systems with less than 2 components." ) - x = solution.time / 60 + x = solution.time + if x_axis_in_minutes: + x = x / 60 + if start is not None: + start = start / 60 + if end is not None: + end = end / 60 if layout is None: layout = plotting.Layout() - layout.x_label = r'$time~/~min$' + layout.x_label = '$time~/~s$' + if x_axis_in_minutes: + layout.x_label = '$time~/~min$' layout.y_label = r'$Purity ~/~\%$' if start is not None: start /= 60 @@ -739,9 +802,6 @@ def __init__( self.time_original = time self.axial_coordinates = axial_coordinates - # Account for dimension reduction in case of only one cell (e.g. LRMP) - if radial_coordinates is not None and len(radial_coordinates) == 1: - radial_coordinates = None self.radial_coordinates = radial_coordinates self.solution_original = solution @@ -765,23 +825,26 @@ def nrad(self): @plotting.create_and_save_figure def plot( self, - start=None, - end=None, - components=None, - layout=None, - y_max=None, - ax=None, + start: float | None = None, + end: float | None = None, + components: list[str] | None = None, + layout: plotting.Layout | None = None, + y_max: float | None = None, + x_axis_in_minutes: bool = True, + ax: Axes | None = None, *args, **kwargs, - ): + ) -> Axes: """Plot the entire time_signal for each component. Parameters ---------- start : float, optional - Start time for plotting. The default is 0. + Start time for plotting in seconds. If None is provided, the first data + point will be used as the start time. The default is None. end : float, optional - End time for plotting. + End time for plotting in seconds. If None is provided, the last data point + will be used as the end time. The default is None. components : list, optional. List of components to be plotted. If None, all components are plotted. layout : plotting.Layout @@ -789,6 +852,8 @@ def plot( y_max : float, optional Maximum value of y axis. If None, value is automatically deferred from solution. + x_axis_in_minutes : bool, optional + If True, the x-axis will be plotted using minutes. The default is True. ax : Axes Axes to plot on. @@ -823,16 +888,20 @@ def plot( coordinates={'time': [start, end]} ) - x = solution.time / 60 + x = solution.time + if x_axis_in_minutes: + x = x / 60 + if start is not None: + start = start / 60 + if end is not None: + end = end / 60 if layout is None: layout = plotting.Layout() - layout.x_label = '$time~/~min$' + layout.x_label = '$time~/~s$' + if x_axis_in_minutes: + layout.x_label = '$time~/~min$' layout.y_label = '$c~/~mM$' - if start is not None: - start /= 60 - if end is not None: - end /= 60 layout.x_lim = (start, end) if y_max is not None: layout.y_lim = (None, y_max) @@ -856,7 +925,7 @@ def plot_at_time( Parameters ---------- t : float - Time for plotting + Time for plotting in seconds. If t == -1, the final solution is plotted. components : list, optional. List of components to be plotted. If None, all components are plotted. @@ -901,10 +970,13 @@ def plot_at_time( @plotting.create_and_save_figure def plot_at_position( self, - z, - components=None, - layout=None, - ax=None, + z: float, + start: float | None = None, + end: float | None = None, + components: list[str] | None = None, + layout: plotting.Layout | None = None, + x_axis_in_minutes: bool = True, + ax: Axes | None = None, *args, **kwargs, ): @@ -914,10 +986,19 @@ def plot_at_position( ---------- z : float Position for plotting. + start : float, optional + Start time for plotting in seconds. If None is provided, the first data + point will be used as the start time. The default is None. + end : float, optional + End time for plotting in seconds. If None is provided, the last data point + will be used as the end time. The default is None. components : list, optional. List of components to be plotted. If None, all components are plotted. layout : plotting.Layout Plot layout options. + If None, value is automatically deferred from solution. + x_axis_in_minutes : bool, optional + If True, the x-axis will be plotted using minutes. The default is True. ax : Axes Axes to plot on. @@ -941,11 +1022,21 @@ def plot_at_position( coordinates={'axial_coordinates': [z, z]}, ) + x = self.time + if x_axis_in_minutes: + x = x / 60 + if start is not None: + start = start / 60 + if end is not None: + end = end / 60 + x = self.time / 60 if layout is None: layout = plotting.Layout() - layout.x_label = '$time~/~min$' + layout.x_label = '$time~/~s$' + if x_axis_in_minutes: + layout.x_label = '$time~/~min$' layout.y_label = '$c~/~mM$' ax = _plot_solution_1D(ax, x, solution, layout, *args, **kwargs) @@ -980,13 +1071,7 @@ def __init__( ): self.axial_coordinates = axial_coordinates - # Account for dimension reduction in case of only one cell (e.g. LRMP) - if radial_coordinates is not None and len(radial_coordinates) == 1: - radial_coordinates = None self.radial_coordinates = radial_coordinates - # Account for dimension reduction in case of only one cell (e.g. CSTR) - if particle_coordinates is not None and len(particle_coordinates) == 1: - particle_coordinates = None self.particle_coordinates = particle_coordinates super().__init__(name, component_system, time, solution) @@ -1007,6 +1092,7 @@ def nrad(self): @property def npar(self): + """int: Number of particle discretization points.""" if self.particle_coordinates is None: return return len(self.particle_coordinates) @@ -1067,7 +1153,6 @@ def _plot_1D( return ax - def _plot_2D(self, t, comp, vmax, ax=None): x = self.axial_coordinates y = self.particle_coordinates @@ -1150,13 +1235,7 @@ def __init__( self.bound_states = bound_states self.axial_coordinates = axial_coordinates - # Account for dimension reduction in case of only one cell (e.g. LRMP) - if radial_coordinates is not None and len(radial_coordinates) == 1: - radial_coordinates = None self.radial_coordinates = radial_coordinates - # Account for dimension reduction in case of only one cell (e.g. CSTR) - if particle_coordinates is not None and len(particle_coordinates) == 1: - particle_coordinates = None self.particle_coordinates = particle_coordinates super().__init__(name, component_system, time, solution) @@ -1183,6 +1262,7 @@ def nrad(self): @property def npar(self): + """int: Number of particle discretization points.""" if self.particle_coordinates is None: return return len(self.particle_coordinates) @@ -1190,23 +1270,26 @@ def npar(self): @plotting.create_and_save_figure def plot( self, - start=None, - end=None, - components=None, - layout=None, - y_max=None, - ax=None, + start: float | None = None, + end: float | None = None, + components: list[str] | None = None, + layout: plotting.Layout | None = None, + y_max: float | None = None, + x_axis_in_minutes: bool = True, + ax: Axes | None = None, *args, **kwargs, - ): - """Plot the entire time_signal for each component. + ) -> Axes: + """Plot the entire solid phase solution for each component. Parameters ---------- start : float, optional - Start time for plotting. The default is 0. + Start time for plotting in seconds. If None is provided, the first data + point will be used as the start time. The default is None. end : float, optional - End time for plotting. + End time for plotting in seconds. If None is provided, the last data point + will be used as the end time. The default is None. components : list, optional. List of components to be plotted. If None, all components are plotted. layout : plotting.Layout @@ -1214,6 +1297,8 @@ def plot( y_max : float, optional Maximum value of y axis. If None, value is automatically deferred from solution. + x_axis_in_minutes : bool, optional + If True, the x-axis will be plotted using minutes. The default is True. ax : Axes Axes to plot on. @@ -1246,16 +1331,20 @@ def plot( coordinates={'time': [start, end]} ) - x = solution.time / 60 + x = solution.time + if x_axis_in_minutes: + x = x / 60 + if start is not None: + start = start / 60 + if end is not None: + end = end / 60 if layout is None: layout = plotting.Layout() - layout.x_label = '$time~/~min$' + layout.x_label = '$time~/~s$' + if x_axis_in_minutes: + layout.x_label = '$time~/~min$' layout.y_label = '$c~/~mM$' - if start is not None: - start /= 60 - if end is not None: - end /= 60 layout.x_lim = (start, end) if y_max is not None: layout.y_lim = (None, y_max) @@ -1266,24 +1355,26 @@ def plot( def _plot_1D( self, - t, - components=None, - layout=None, - ax=None, + t: float, + components: list[str] | None = None, + layout: plotting.Layout | None = None, + ax: Axes | None = None, *args, **kwargs, - ): + ) -> Axes: """Plot bulk solution over space at given time. Parameters ---------- t : float - Time for plotting + Time for plotting, in seconds. If t == -1, the final solution is plotted. - components : list, optional. + components : list, optional List of components to be plotted. If None, all components are plotted. - layout : plotting.Layout - Plot layout options. + The default is None. + layout : plotting.Layout, optional + Plot layout options. If None, a new instance is created. + The default is None. ax : Axes Axes to plot on. @@ -1381,38 +1472,57 @@ class SolutionVolume(SolutionBase): @property def solution_shape(self): """tuple: (Expected) shape of the solution""" - return (self.nt, 1) + return (self.nt, ) @plotting.create_and_save_figure - def plot(self, start=None, end=None, ax=None, update_layout=True, **kwargs): - """Plot the whole time_signal for each component. + def plot( + self, + start: float | None = None, + end: float | None = None, + x_axis_in_minutes: bool = True, + ax: Axes | None = None, + update_layout: bool = True, + **kwargs + ) -> Axes: + """Plot the unit operation's volume over time. Parameters ---------- - start : float - start time for plotting - end : float - end time for plotting + start : float, optional + Start time for plotting in seconds. If None is provided, the first data + point will be used as the start time. The default is None. + end : float, optional + End time for plotting in seconds. If None is provided, the last data point + will be used as the end time. The The default is None. + x_axis_in_minutes : bool, optional + If True, the x-axis will be plotted using minutes. The default is True. ax : Axes Axes to plot on. + update_layout : bool, optional + If True, update the figure's layout. The default is True. See Also -------- CADETProcess.plot """ - x = self.time / 60 + x = self.time + if x_axis_in_minutes: + x = x / 60 + if start is not None: + start = start / 60 + if end is not None: + end = end / 60 + y = self.solution * 1000 y_min = np.min(y) y_max = 1.1 * np.max(y) layout = plotting.Layout() - layout.x_label = '$time~/~min$' + layout.x_label = '$time~/~s$' + if x_axis_in_minutes: + layout.x_label = '$time~/~min$' layout.y_label = '$V~/~L$' - if start is not None: - start /= 60 - if end is not None: - end /= 60 layout.x_lim = (start, end) layout.y_lim = (y_min, y_max) ax.plot(x, y, **kwargs) @@ -1570,9 +1680,9 @@ def __init__(self, time, signal): if len(signal.shape) == 1: signal = np.array(signal, ndmin=2).transpose() self._solutions = [ - PchipInterpolator(time, signal[:, comp]) - for comp in range(signal.shape[1]) - ] + PchipInterpolator(time, signal[:, comp]) + for comp in range(signal.shape[1]) + ] self._derivatives = [signal.derivative() for signal in self._solutions] self._antiderivatives = [signal.antiderivative() for signal in self._solutions] diff --git a/CADETProcess/transform.py b/CADETProcess/transform.py index dc1db1b9..afcb67c5 100644 --- a/CADETProcess/transform.py +++ b/CADETProcess/transform.py @@ -22,6 +22,7 @@ from abc import ABC, abstractmethod, abstractproperty import numpy as np +from matplotlib.axes import Axes import matplotlib.pyplot as plt from CADETProcess import plotting @@ -239,7 +240,7 @@ def plot(self, ax, use_log_scale=False): Parameters ---------- - ax : matplotlib.axes.Axes + ax : Axes The axes object to plot on. use_log_scale : bool, optional If True, use a logarithmic scale for the x-axis. diff --git a/CITATION.bib b/CITATION.bib new file mode 100644 index 00000000..4b71491c --- /dev/null +++ b/CITATION.bib @@ -0,0 +1,13 @@ +% As an open-source project, CADET-Process relies on the support and recognition from users and researchers to thrive. +% Therefore, we kindly ask that any publications or projects leveraging the capabilities of CADET-Process acknowledge its creators and their contributions by citing an adequate selection of our publications. + +@Article{Schmoelder2020, + author = {Schmölder, Johannes and Kaspereit, Malte}, + title = {A {{Modular Framework}} for the {{Modelling}} and {{Optimization}} of {{Advanced Chromatographic Processes}}}, + doi = {10.3390/pr8010065}, + number = {1}, + pages = {65}, + volume = {8}, + journal = {Processes}, + year = {2020}, +} diff --git a/README.md b/README.md index 7babb6d6..4f5b3344 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ pip install CADET-Process ``` To use **CADET-Process**, make sure, that **CADET** is also installed. -This can for example be done using [conda](https://docs.conda.io/en/latest/): +This can for example be done using [conda](https://github.com/conda-forge/miniforge): ``` conda install -c conda-forge cadet diff --git a/docs/environment.yml b/docs/environment.yml index ad46cca9..69c144d4 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -2,6 +2,6 @@ name: cadet-process-docs channels: - conda-forge dependencies: - - python=3.11 - - cadet>=4.4.0 + - python=3.12 + - cadet>=5.0.3 - pip diff --git a/docs/source/release_notes/index.md b/docs/source/release_notes/index.md index 50a7584d..b46ccf51 100644 --- a/docs/source/release_notes/index.md +++ b/docs/source/release_notes/index.md @@ -12,4 +12,5 @@ v0.7.3 v0.8.0 v0.9.0 v0.9.1 +v0.10.0 ``` diff --git a/docs/source/release_notes/v0.10.0.md b/docs/source/release_notes/v0.10.0.md new file mode 100644 index 00000000..c16d7864 --- /dev/null +++ b/docs/source/release_notes/v0.10.0.md @@ -0,0 +1,92 @@ +# v0.10.0 + +**CADET-Process** v0.10.0 is the culmination of 7 months of dedicated development and collaboration. +This release introduces significant new features, critical bug fixes, enhanced test coverage, and improved documentation. +We strongly encourage all users to upgrade to this version for better performance and new functionalities. + +This release requires Python 3.10+. + +## Highlights and new features of this release + +### {mod}`CADETProcess.processModel` improvements + +- **Support for Unit Operations with Multiple Ports**: + Enhanced flexibility in modeling systems by introducing support for unit operations that handle multiple input and output ports. + +- **Add MCT Model**: + A new Model for Multicomponent Transport (MCT) is introduced, expanding the capabilities of the library for simulating advanced separation processes. + +### {mod}`CADETProcess.comparison` improvements + +- **Add Fractionation Metric**: + A new metric for assessing the fractionation efficiency of processes has been added, improving the comparison and analysis of simulation results. + +### {mod}`CADETProcess.simulator` improvements + +- **Integration of DG method**: + Adaptation to the new spatial discontinuous Galerkin discretization method (DG) of CADET-Core, enhancing the computational performance of chromatography models. + Please refer to our corresponding publication and the updated documentation for information on optimal discretization parameters. +- **Integration of C-API**: + Adaptation to the new in-memory interface of CADET-Core, improving the speed of simulation startup and writing of solution output. + +### {mod}`CADETProcess.optimization` improvements + +- **Add qNParEGO Optimization**: + Integration of the qNParEGO interface, providing support for advanced multi-objective optimization with faster convergence and broader applicability. + +- **General Optimization Improvements**: + Multiple refinements and updates to optimization processes, including improved handling of constraints and optimization problem setup. + +## Issues closed for 0.10.0 + +- [6](https://github.com/fau-advanced-separations/CADET-Process/issues/6): Use CADET C-API +- [139](https://github.com/fau-advanced-separations/CADET-Process/issues/139): Connecting Outlet unit operation to other operations now raises a proper Exception. +- [151](https://github.com/fau-advanced-separations/CADET-Process/issues/151): Fixed divide-by-zero error in `pearsonr_mat` when simulations finish without elution. +- [160](https://github.com/fau-advanced-separations/CADET-Process/issues/160): GRM ParticleSolution errors for npar = 1 +- [164](https://github.com/fau-advanced-separations/CADET-Process/issues/164): Bug in component system with molecular_weight +- [174](https://github.com/fau-advanced-separations/CADET-Process/issues/174): ImportError: cannot import name 'FixedNoiseGP' from 'botorch.models.gp_regression' +- [176](https://github.com/fau-advanced-separations/CADET-Process/issues/176): Can't simulate LRMP with ncols = 1 +- [178](https://github.com/fau-advanced-separations/CADET-Process/issues/178): Addressed exceptions in Ax optimizer options. +- [183](https://github.com/fau-advanced-separations/CADET-Process/issues/183): Add a Release Guide for CADET-Process +- [193](https://github.com/fau-advanced-separations/CADET-Process/issues/193): Add tests that check n_par = 1 and n_col = 1 works for UOs that allow this specification + +## Pull requests for 0.10.0 + +- [86](https://github.com/fau-advanced-separations/CADET-Process/pull/86): Add fractionation metric. +- [108](https://github.com/fau-advanced-separations/CADET-Process/pull/108): Add Fanout Cache. +- [109](https://github.com/fau-advanced-separations/CADET-Process/pull/109): Calculate volumetric flow from u. +- [127](https://github.com/fau-advanced-separations/CADET-Process/pull/127): Unify calling evaluation functions for individuals and populations. +- [130](https://github.com/fau-advanced-separations/CADET-Process/pull/130): Add qNParEGO Ax MOO Interface. +- [137](https://github.com/fau-advanced-separations/CADET-Process/pull/137): Always inherit cadet path. +- [138](https://github.com/fau-advanced-separations/CADET-Process/pull/138): Fix documentation for AntiLangmuir isotherm. +- [140](https://github.com/fau-advanced-separations/CADET-Process/pull/140): Fix add_concentration_profile +- [141](https://github.com/fau-advanced-separations/CADET-Process/pull/141): Adapt to new DG interface in CADET-Core. +- [142](https://github.com/fau-advanced-separations/CADET-Process/pull/142): Fix/use minutes. +- [143](https://github.com/fau-advanced-separations/CADET-Process/pull/143): Fix/inlet outlet connections. +- [148](https://github.com/fau-advanced-separations/CADET-Process/pull/148): Improve tearDown after tests. +- [149](https://github.com/fau-advanced-separations/CADET-Process/pull/149): Enable colloidal binding. +- [150](https://github.com/fau-advanced-separations/CADET-Process/pull/150): Avoid duplicate entries in user_solution_times. +- [152](https://github.com/fau-advanced-separations/CADET-Process/pull/152): Optimization improvements. +- [154](https://github.com/fau-advanced-separations/CADET-Process/pull/154): Fix plot_at_position. +- [155](https://github.com/fau-advanced-separations/CADET-Process/pull/155): Fix pearsonr_mat divide by zero error. +- [157](https://github.com/fau-advanced-separations/CADET-Process/pull/157): Add create_LWE. +- [159](https://github.com/fau-advanced-separations/CADET-Process/pull/159): Support numpy v2. +- [163](https://github.com/fau-advanced-separations/CADET-Process/pull/163): Add linear threshold parameter for mobile phase modulator +- [165](https://github.com/fau-advanced-separations/CADET-Process/pull/165): Remove setup.cfg. +- [167](https://github.com/fau-advanced-separations/CADET-Process/pull/167): Optimizer improvement v2 +- [169](https://github.com/fau-advanced-separations/CADET-Process/pull/169): Adapt to CADET-Core v5. +- [170](https://github.com/fau-advanced-separations/CADET-Process/pull/170): Fix loading of multi-cycle solutions. +- [179](https://github.com/fau-advanced-separations/CADET-Process/pull/179): Resolves: ImportError cannot import name FixedNoiseGP from botorch.models.gp_regression +- [184](https://github.com/fau-advanced-separations/CADET-Process/pull/184): Fix pyproject.toml +- [185](https://github.com/fau-advanced-separations/CADET-Process/pull/185): Fix solution dimensions +- [186](https://github.com/fau-advanced-separations/CADET-Process/pull/186): Add release guide +- [188](https://github.com/fau-advanced-separations/CADET-Process/pull/188): Updates test_cadet_adapter to new CADET-Core (+ minor bug fix) +- [191](https://github.com/fau-advanced-separations/CADET-Process/pull/191): Fix recursion error in ComponentSystem.molecular_weights +- [195](https://github.com/fau-advanced-separations/CADET-Process/pull/195): Update MacOS in CI and reintroduce tests on Windows +- [196](https://github.com/fau-advanced-separations/CADET-Process/pull/196): Add parameterized tests to test_cadet_adapter and fixes bug in create_LWE +- [202](https://github.com/fau-advanced-separations/CADET-Process/pull/202): Fix C-API +- [203](https://github.com/fau-advanced-separations/CADET-Process/pull/203): Update conda link in README.md + +--- + +**Full Changelog**: [Compare v0.9.0 to v0.10.0](https://github.com/fau-advanced-separations/CADET-Process/compare/v0.9.0...v0.10.0) diff --git a/docs/source/user_guide/optimization/optimization_problem.md b/docs/source/user_guide/optimization/optimization_problem.md index ffa40823..ebf15650 100644 --- a/docs/source/user_guide/optimization/optimization_problem.md +++ b/docs/source/user_guide/optimization/optimization_problem.md @@ -16,6 +16,7 @@ sys.path.append('../../../../') (optimization_problem_guide)= # Optimization Problem + The {class}`~CADETProcess.optimization.OptimizationProblem` class is used to specify optimization variables, objectives and constraints. To instantiate a {class}`~CADETProcess.optimization.OptimizationProblem`, a name needs to be passed as argument. @@ -30,6 +31,7 @@ In contrast to using a simple python dictionary, this also allows for multi-core (optimization_variables_guide)= ## Optimization Variables + Any number of variables can be added to the {class}`~CADETProcess.optimization.OptimizationProblem`. To add a variable, use the {meth}`~CADETProcess.optimization.OptimizationProblem.add_variable` method. The first argument is the name of the variable. @@ -56,12 +58,13 @@ For more information on how to configure optimization variables of multi-dimensi (objectives_guide)= ## Objectives + Any callable function that takes an input $x$ and returns objectives $f$ can be added to the {class}`~CADETProcess.optimization.OptimizationProblem`. Consider a quadratic function which expects a single input and returns a single output: ```{code-cell} ipython3 def objective(x): - return x**2 + return x**2 ``` To add this function as objective, use the {meth}`~CADETProcess.optimization.OptimizationProblem.add_objective` method. @@ -77,7 +80,6 @@ $$ f(x,y) = [x^2 + y^2, (x-2)^2 + (y-2)^2] $$ - Either two callables are added as objective ```{code-cell} ipython3 @@ -92,12 +94,12 @@ optimization_problem.add_variable('y', lb=-10, ub=10) import numpy as np def objective_1(x): - return x[0]**2 + x[1]**2 + return x[0]**2 + x[1]**2 optimization_problem.add_objective(objective_1) def objective_2(x): - return (x[0] - 2)**2 + (x[1] - 2)**2 + return (x[0] - 2)**2 + (x[1] - 2)**2 optimization_problem.add_objective(objective_2) ``` @@ -115,9 +117,9 @@ optimization_problem.add_variable('y', lb=-10, ub=10) ```{code-cell} ipython3 def multi_objective(x): - f_1 = x[0]**2 + x[1]**2 - f_2 = (x[0] - 2)**2 + (x[1] - 2)**2 - return np.hstack((f_1, f_2)) + f_1 = x[0]**2 + x[1]**2 + f_2 = (x[0] - 2)**2 + (x[1] - 2)**2 + return np.hstack((f_1, f_2)) optimization_problem.add_objective(multi_objective, n_objectives=2) ``` @@ -136,16 +138,16 @@ The objective(s) can be evaluated with the {meth}`~CADETProcess.optimization.Opt optimization_problem.evaluate_objectives([1, 1]) ``` -It is also possible to evaluate multiple sets of input variables at once by passing a 2D array to the {meth}`~CADETProcess.optimization.OptimizationProblem.evaluate_objectives_population` method. +It is also possible to evaluate multiple sets of input variables at once by passing a 2D array to the {meth}`~CADETProcess.optimization.OptimizationProblem.evaluate_objectives` method. ```{code-cell} ipython3 -optimization_problem.evaluate_objectives_population([[0, 1], [1, 1], [2, -1]]) +optimization_problem.evaluate_objectives([[0, 1], [1, 1], [2, -1]]) ``` For more complicated scenarios that require (multiple) preprocessing steps, refer to {ref}`evaluation_toolchains_guide`. - ## Linear constraints + Linear constraints are a common way to restrict the feasible region of an optimization problem. They are typically defined using linear functions of the optimization: @@ -201,6 +203,7 @@ optimization_problem.check_linear_constraints([0, 0, 0, 0]) (nonlinear_constraints_guide)= ## Nonlinear constraints + In addition to linear constraints, nonlinear constraints can be added to the optimization problem. To add nonlinear constraints, use the {meth}`~CADETProcess.optimization.OptimizationProblem.add_nonlinear_constraint` method. @@ -257,6 +260,7 @@ optimization_problem.add_nonlinear_constraint(nonlincon, bounds=1) ``` Note that {meth}`~CADETProcess.optimization.OptimizationProblem.evaluate_nonlinear_constraints` still returns the same value. + ```{code-cell} ipython3 optimization_problem.evaluate_nonlinear_constraints([0.5, 0.5]) ``` @@ -279,6 +283,7 @@ For more complicated scenarios that require (multiple) preprocessing steps, refe (initial_values_creation_guide)= ## Initial Values + Initial values in optimization refer to the starting values of the decision variables for the optimization algorithm. These values are typically set by the user and serve as a starting point for the optimization algorithm to begin its search for the optimal solution. The choice of initial values can have a significant impact on the success of the optimization, as a poor choice may lead to the algorithm converging to a suboptimal solution or failing to converge at all. @@ -331,6 +336,7 @@ fig.tight_layout() ``` ## Callbacks + A callback function is a user defined function that is called periodically by the optimizer in order to allow the user to query the state of the optimization. For example, a simple user callback function might be used to plot results. @@ -342,6 +348,7 @@ For more information on controlling the members of the Pareto front, refer to {r ``` The callback signature may include any of the following arguments: + - results : obj x or final result of evaluation toolchain. - individual : {class}`~CADETProcess.optimization.Individual`, optional diff --git a/docs/source/user_guide/optimization/variable_dependencies.md b/docs/source/user_guide/optimization/variable_dependencies.md index 93970fea..5cfd2d03 100644 --- a/docs/source/user_guide/optimization/variable_dependencies.md +++ b/docs/source/user_guide/optimization/variable_dependencies.md @@ -15,7 +15,9 @@ sys.path.append('../../../../') ``` (variable_dependencies_guide)= + # Variable Dependencies + In many optimization problems, a large number of variables must be considered simultaneously, leading to high complexity. For more advanced problems, reducing the degrees of freedom can greatly simplify the optimization process and lead to faster convergence and better results. One way to achieve this is to define dependencies between individual variables. @@ -29,7 +31,6 @@ For example, consider a process where the same parameter is used in multiple uni To reduce the number of variables that the optimizer needs to consider, it is possible to add a single variable, which is then set on both evaluation objects in pre-processing. In other cases, the ratio between model parameters may be essential for the optimization problem. - ```{figure} ./figures/transform_dependency.svg :name: transform_dependency ``` @@ -66,6 +67,14 @@ Both influence the strength of the interaction as well as the dynamics of the in By using the transformation $k_{eq} = k_a / k_d$ to calculate the equilibrium constant and $k_{kin} = 1 / k_d$ to calculate the kinetics constant, the values for the equilibrium and the kinetics of the reaction can be identified independently. First, the dependent variables $k_a$ and $k_d$ must be added as they are implemented in the underlying model. +```{code-cell} ipython3 +:tags: [remove-cell] + +from examples.load_wash_elute.lwe_flow_rate import process +optimization_problem = OptimizationProblem('adsorption_rate_demo') +optimization_problem.add_evaluation_object(process) +``` + ```{code-cell} ipython3 optimization_problem.add_variable( name='adsorption_rate', @@ -120,3 +129,37 @@ optimization_problem.add_variable_dependency( transform=lambda k_kin, k_eq: k_eq / k_kin ) ``` + +```python +from CADETProcess.optimization import OptimizationProblem +optimization_problem = OptimizationProblem('transform_demo') + +optimization_problem.add_variable('var_0') +optimization_problem.add_variable('var_1') +optimization_problem.add_variable('var_2') +``` + +```python +def transform_fun(var_0, var_1): + return var_0/var_1 + +optimization_problem.add_variable_dependency('var_2', ['var_0', 'var_1'], transform=transform_fun) +``` + +```python +optimization_problem.add_variable( + name='adsorption_rate', + parameter_path='flow_sheet.column.binding_model.adsorption_rate', + lb=1e-3, ub=1e3, + transform='auto', + indices=[1] # modify only the protein (component index 1) parameter +) + +optimization_problem.add_variable( + name='desorption_rate', + parameter_path='flow_sheet.column.binding_model.desorption_rate', + lb=1e-3, ub=1e3, + transform='auto', + indices=[1] +) +``` diff --git a/docs/source/user_guide/process_model/unit_operation.md b/docs/source/user_guide/process_model/unit_operation.md index 69e7d87e..45c9e1d0 100644 --- a/docs/source/user_guide/process_model/unit_operation.md +++ b/docs/source/user_guide/process_model/unit_operation.md @@ -16,6 +16,7 @@ sys.path.append('../../../../') (unit_operation_guide)= # Unit Operation Models + A {class}`UnitOperation ` is a class that represents the physico-chemical behavior of an apparatus and holds its model parameters. For an overview of all unit operation models currently available in **CADET-Process**, refer to {mod}`~CADETProcess.processModel`. Each unit operation model can be associated with binding models that describe the interaction of components with the surface of a chromatographic stationary phase. @@ -41,6 +42,7 @@ unit = Cstr(component_system, 'tank') ``` All parameters are stored in the {attr}`~CADETProcess.processModel.UnitBaseModel.parameters` attribute. + ```{code-cell} ipython3 print(unit.parameters) ``` @@ -91,7 +93,6 @@ print(inlet.flow_rate) Or, specify all polynomial coefficients: - ```{code-cell} ipython3 inlet.flow_rate = [0, 1, 2, 3] print(inlet.flow_rate) @@ -108,7 +109,6 @@ print(inlet.c) Specify constant term for each component: - ```{code-cell} ipython3 inlet.c = [1, 2] print(inlet.c) @@ -133,7 +133,8 @@ Since these parameters are mostly used in dynamic process models, they are usual For an example, refer to {ref}`SSR process `. (discretization_guide)= -### Discretization +## Discretization + Some of the unit operations need to be spatially discretized. The discretization parameters are stored in a {class}`DiscretizationParametersBase` class. For example, consider a {class}`~CADETProcess.processModel.LumpedRateModelWithoutPores`. @@ -156,6 +157,7 @@ print(lrm_discretization_fv.parameters) ``` Notable parameters are: + - {attr}`~CADETProcess.processModel.LRMDiscretizationFV.ncol`: Number of axial column discretization cells. Default is 100. - {attr}`~CADETProcess.processModel.LRMDiscretizationFV.weno_parameters`: Discretization parameters for the WENO scheme - {attr}`~CADETProcess.processModel.LRMDiscretizationFV.consistency_solver`: Consistency solver parameters for Cadet. @@ -186,6 +188,7 @@ print(lrm_dg.discretization) (solution_recorder_guide)= ## Solution Recorder + To store the solution of a unit operation, a solution recorder needs to be configured. In this recorder, a flag can be set to store different partitions (e.g. inlet, outlet, bulk, etc.) of the solution of that unit operation. Consider the {attr}`~CADETProcess.processModel.LumpedRateModelWithoutPores.solution_recorder` of a {class}`~CADETProcess.processModel.LumpedRateModelWithoutPores`. diff --git a/docs/source/user_guide/simulator.md b/docs/source/user_guide/simulator.md index fcd41699..1dc0ad03 100644 --- a/docs/source/user_guide/simulator.md +++ b/docs/source/user_guide/simulator.md @@ -17,7 +17,9 @@ sys.path.append('../../../') ``` (simulation_guide)= + # Process Simulation + To simulate a {class}`~CADETProcess.processModel.Process`, a simulator needs to be configured. The simulator translates the {class}`~CADETProcess.processModel.Process` configuration into the API of the corresponding external simulator. As of now, only **CADET** has been adapted but in principle, other simulators can be also implemented. @@ -30,6 +32,7 @@ mamba install -c conda-forge cadet For more information on **CADET**, refer to the {ref}`CADET Documentation ` ## Instantiate Simulator + First, {class}`~CADETProcess.simulator.Cadet` needs to be imported. If no path is specified in the constructor, **CADET-Process** will try to autodetect the **CADET** installation. @@ -51,9 +54,11 @@ process_simulator.check_cadet() ``` ## Simulator Parameters + For all simulator parameters, reasonable default values are provided but there might be cases where those might need to be changed. ### Time Stepping + **CADET** uses adaptive time stepping. That is, the time step size is dynamically adjusted based on the rate of change of the variables being simulated. This balances the tradoff between simulation accuracy and computational efficiency by reducing the time step size when the error estimate is larger than a specified tolerance and increasing it when the error estimate is smaller. @@ -76,6 +81,7 @@ Most notably, {attr}`~CADETProcess.simulator.SolverTimeIntegratorParameters.abst For more information, see {class}`~CADETProcess.simulator.SolverTimeIntegratorParameters` and refer to the {ref}`CADET Documentation`. ### Solver Parameters + The {class}`~CADETProcess.simulator.SolverParameters` stores general parameters of the solver. ```{code-cell} ipython3 @@ -86,6 +92,7 @@ Most notably, {attr}`~CADETProcess.simulator.SolverParameters.nthreads` defines For more information, see also {ref}`CADET Documentation`. ### Model Solver Parameters + The {class}`~CADETProcess.simulator.ModelSolverParameters` stores general parameters of the model solver. ```{code-cell} ipython3 @@ -95,6 +102,7 @@ print(process_simulator.solver_parameters) For more information, see also {ref}`CADET Documentation`. ## Simulate Processes + To run the simulation, pass the {class}`~CADETProcess.processModel.Process` as an argument to the {meth}`~CADETProcess.simulator.Cadet.simulate` method. For this example, consider a simple {ref}`batch-elution example`. @@ -110,16 +118,20 @@ simulation_results = process_simulator.simulate(process) ``` Sometimes simulations can take a long time to finish. -To limit their runtime, add a `timeout` argument with the maximum simulation time in seconds. +To limit their runtime, set the `timeout` attribute of the simulator. ``` -simulation_results = process_simulator.simulate(process, timeout=300) +process_simulator.timeout = 300 +simulation_results = process_simulator.simulate(process) ``` (simulation_results_guide)= + ## Simulation Results + The {class}`~CADETProcess.simulationResults.SimulationResults` object contains the results of the simulation. This includes: + - `exit_code`: Information about the solver termination. - `exit_message`: Additional information about the solver status. - `time_elapsed`: Execution time of simulation. @@ -171,12 +183,15 @@ print(simulation_results.sensitivity['column.total_porosity'].column.keys()) ``` Here, the `outlet` entry again is a {class}`~CADETProcess.solution.SolutionIO` which can be plotted. + ```{code-cell} ipython3 _ = simulation_results.sensitivity['column.total_porosity'].column.outlet.plot() ``` (stationarity_guide)= + ## Cyclic Stationarity + Preparative chromatographic separations are operated in a repetitive fashion. In particular processes that incorporate the recycling of streams, like steady-state-recycling (SSR) or simulated moving bed (SMB), have a distinct startup behavior that takes multiple cycles until a periodic steady state is reached. But also in conventional batch chromatography several cycles are needed to attain stationarity in optimized situations where there is a cycle-to-cycle overlap of the elution profiles of consecutive injections. @@ -204,6 +219,7 @@ simulation_results = process_simulator.simulate(process) _ = simulation_results.solution.column.outlet.plot() ``` + However, it is hard to anticipate, when steady state is reached. To automatically simulate until stationarity is reached, a {class}`~CADETProcess.stationarity.StationarityEvaluator` needs to be configured. @@ -253,6 +269,7 @@ _ = simulation_results.solution.column.outlet.plot() ``` The number of cycles is stored in the simulation results. + ```{code-cell} ipython3 print(simulation_results.n_cycles) ``` diff --git a/environment.yml b/environment.yml index 3947e2e4..1829f5e0 100644 --- a/environment.yml +++ b/environment.yml @@ -3,5 +3,5 @@ channels: - conda-forge dependencies: - python>=3.10.* - - cadet + - cadet>=5.0.3 - pip diff --git a/pyproject.toml b/pyproject.toml index 7797ba86..6013bdf7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "addict==2.3", - "cadet-python>=0.14", + "cadet-python>=1.0.4", "corner>=2.2.1", "diskcache>=5.4.0", "hopsy>=1.4.0", @@ -43,7 +43,7 @@ testing = [ "certifi", # tries to prevent certificate problems on windows "pytest", "pre-commit", # system tests run pre-commit - "ax-platform>=0.3.5", + "ax-platform >=0.3.5" ] docs = [ "myst-nb>=0.17.1", @@ -56,7 +56,7 @@ docs = [ ] ax = [ - "ax-platform>=0.3.5", + "ax-platform >=0.3.5" ] [project.urls] @@ -64,6 +64,8 @@ homepage = "https://github.com/fau-advanced-separations/CADET-Process" documentation = "https://cadet-process.readthedocs.io" "Bug Tracker" = "https://github.com/fau-advanced-separations/CADET-Process/issues" +[tool.setuptools.packages.find] +include = ["CADETProcess*", "examples*", "tests*"] [tool.setuptools.dynamic] version = { attr = "CADETProcess.__version__" } diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b918daeb..00000000 --- a/setup.cfg +++ /dev/null @@ -1,65 +0,0 @@ -[metadata] -name = CADET-Process -version = 0.9.0 -author = Johannes Schmölder -author_email = j.schmoelder@fz-juelich.de -description = A Framework for Modelling and Optimizing Advanced Chromatographic Processes -long_description = file: README.md -long_description_content_type = text/markdown -url = https://github.com/fau-advanced-separations/CADET-Process -project_urls = - Bug Tracker = https://github.com/fau-advanced-separations/CADET-Process/issues -classifiers = - Programming Language :: Python :: 3 - License :: OSI Approved :: GNU General Public License v3 (GPLv3) - Operating System :: OS Independent - -[options] -packages = find: -python_requires = >=3.10 -install_requires = - numpy>=1.21 - scipy>=1.11 - matplotlib>=3.4 - corner>=2.2.1 - sympy>=1.8 - pathos>=0.2.8 - addict==2.3 - cadet-python>=0.14 - hopsy>=1.4.0 - pymoo>=0.6 - numba>=0.55.1 - diskcache>=5.4.0 - joblib>=1.3.0 - psutil>=5.9.8 - -[options.extras_require] -testing = - setuptools - certifi # tries to prevent certificate problems on windows - pre-commit # system tests run pre-commit - flake8 # system tests run flake8 - pytest - ax-platform>=0.3.5 -docs = - sphinx>=5.3.0 - sphinxcontrib-bibtex>=2.5.0 - sphinx_book_theme>=1.0.0 - sphinx_copybutton>=0.5.1 - sphinx-sitemap>=2.5.0 - numpydoc>=1.5.0 - myst-nb>=0.17.1 -ax = - ax-platform>=0.3.5 - -[flake8] -max_line_length = 88 -exclude = - build - dist - .eggs - docs/conf.py - - -[tool.pytest] -pythonpath = tests diff --git a/tests/create_LWE.py b/tests/create_LWE.py new file mode 100644 index 00000000..ecddeefc --- /dev/null +++ b/tests/create_LWE.py @@ -0,0 +1,417 @@ +import numpy as np + +from CADETProcess.processModel import ( + ComponentSystem, FlowSheet, Process, + Inlet, Outlet, Cstr, GeneralRateModel, + TubularReactor, LumpedRateModelWithoutPores, + LumpedRateModelWithPores, MCT, StericMassAction +) + + +def create_lwe(unit_type: str = 'GeneralRateModel', **kwargs) -> Process: + """ + Create a process with the specified unit type and configuration. + + Parameters + ---------- + unit_type : str, optional + The type of unit operation, by default 'GeneralRateModel'. + **kwargs : dict + Additional parameters for configuring the unit operation. + + Returns + ------- + Process + The configured process. + """ + n_comp: int = kwargs.get('n_comp', 4) + component_system = ComponentSystem(n_comp) + + if unit_type == 'Cstr': + unit = configure_cstr(component_system, **kwargs) + elif unit_type == 'GeneralRateModel': + unit = configure_general_rate_model(component_system, **kwargs) + elif unit_type == 'TubularReactor': + unit = configure_tubular_reactor(component_system, **kwargs) + elif unit_type == 'LumpedRateModelWithoutPores': + unit = configure_lumped_rate_model_without_pores(component_system, **kwargs) + elif unit_type == 'LumpedRateModelWithPores': + unit = configure_lumped_rate_model_with_pores(component_system, **kwargs) + elif unit_type == 'MCT': + unit = configure_multichannel_transport_model(component_system, **kwargs) + else: + raise ValueError(f'Unknown unit operation type {unit_type}') + + flow_sheet = setup_flow_sheet(unit, component_system) + + process = Process(flow_sheet, 'process') + process.cycle_time = 120 * 60 + + c1_lwe = [[50.0], [0.0], [[100.0, 0.2]]] + cx_lwe = [[1.0], [0.0], [0.0]] + + process.add_event( + 'load', 'flow_sheet.inlet.c', + c1_lwe[0] + cx_lwe[0] * (n_comp - 1), 0 + ) + process.add_event( + 'wash', 'flow_sheet.inlet.c', + c1_lwe[1] + cx_lwe[1] * (n_comp - 1), 10 + ) + process.add_event( + 'elute', 'flow_sheet.inlet.c', + c1_lwe[2] + cx_lwe[2] * (n_comp - 1), 90 + ) + + return process + + +def setup_flow_sheet(unit, component_system: ComponentSystem) -> FlowSheet: + """ + Set up the flow sheet for the process. + + Parameters + ---------- + unit : UnitOperation + The unit operation to be added to the flow sheet. + component_system : ComponentSystem + The component system of the process. + + Returns + ------- + FlowSheet + The configured flow sheet. + """ + flow_sheet = FlowSheet(component_system) + inlet = Inlet(component_system, name='inlet') + inlet.flow_rate = 1.2e-3 + outlet = Outlet(component_system, name='outlet') + + flow_sheet.add_unit(inlet) + flow_sheet.add_unit(unit) + flow_sheet.add_unit(outlet) + + if unit.has_ports: + flow_sheet.add_connection(inlet, unit, destination_port='channel_0') + flow_sheet.add_connection(unit, outlet, origin_port='channel_0') + else: + flow_sheet.add_connection(inlet, unit) + flow_sheet.add_connection(unit, outlet) + + return flow_sheet + + +def configure_cstr(component_system: ComponentSystem, **kwargs) -> Cstr: + """ + Configure a continuous stirred-tank reactor (CSTR). + + Parameters + ---------- + component_system : ComponentSystem + The component system of the process. + **kwargs : dict + Additional parameters for configuring the CSTR. + + Returns + ------- + Cstr + The configured CSTR. + """ + cstr = Cstr(component_system, name='Cstr') + + total_volume = 1e-3 + total_porosity = 0.37 + (1.0 - 0.37) * 0.75 + cstr.init_liquid_volume = total_porosity * total_volume + cstr.const_solid_volume = (1 - total_porosity) * total_volume + + configure_solution_recorder(cstr, **kwargs) + configure_steric_mass_action(cstr, component_system, **kwargs) + + return cstr + + +def configure_general_rate_model(component_system: ComponentSystem, **kwargs) -> GeneralRateModel: + """ + Configure a general rate model. + + Parameters + ---------- + component_system : ComponentSystem + The component system of the process. + **kwargs : dict + Additional parameters for configuring the general rate model. + + Returns + ------- + GeneralRateModel + The configured general rate model. + """ + grm = GeneralRateModel(component_system, name='GeneralRateModel') + + grm.length = 0.014 + grm.diameter = 0.01 * 2 + grm.bed_porosity = 0.37 + grm.axial_dispersion = 5.75e-8 + grm.pore_diffusion = [7e-10, 6.07e-11, 6.07e-11, 6.07e-11] + + configure_solution_recorder(grm, **kwargs) + configure_discretization(grm, **kwargs) + configure_particles(grm) + configure_steric_mass_action(grm, component_system, **kwargs) + configure_film_diffusion(grm, component_system.n_comp) + configure_flow_direction(grm, **kwargs) + + return grm + + +def configure_tubular_reactor(component_system: ComponentSystem, **kwargs) -> TubularReactor: + """ + Configure a tubular reactor. + + Parameters + ---------- + component_system : ComponentSystem + The component system of the process. + **kwargs : dict + Additional parameters for configuring the tubular reactor. + + Returns + ------- + TubularReactor + The configured tubular reactor. + """ + tr = TubularReactor(component_system, name='TubularReactor') + + tr.length = 0.014 + tr.diameter = 0.01 * 2 + tr.axial_dispersion = 5.75e-8 + + configure_solution_recorder(tr, **kwargs) + configure_discretization(tr, **kwargs) + configure_flow_direction(tr, **kwargs) + + return tr + + +def configure_lumped_rate_model_without_pores(component_system: ComponentSystem, **kwargs) -> LumpedRateModelWithoutPores: + """ + Configure a lumped rate model without pores. + + Parameters + ---------- + component_system : ComponentSystem + The component system of the process. + **kwargs : dict + Additional parameters for configuring the lumped rate model. + + Returns + ------- + LumpedRateModelWithoutPores + The configured lumped rate model. + """ + lrm = LumpedRateModelWithoutPores( + component_system, name='LumpedRateModelWithoutPores' + ) + + lrm.length = 0.014 + lrm.diameter = 0.01 * 2 + lrm.total_porosity = 0.37 + (1.0 - 0.37) * 0.75 + lrm.axial_dispersion = 5.75e-8 + + configure_solution_recorder(lrm, **kwargs) + configure_discretization(lrm, **kwargs) + configure_steric_mass_action(lrm, component_system, **kwargs) + configure_flow_direction(lrm, **kwargs) + + return lrm + + +def configure_lumped_rate_model_with_pores(component_system: ComponentSystem, **kwargs) -> LumpedRateModelWithPores: + """ + Configure a lumped rate model with pores. + + Parameters + ---------- + component_system : ComponentSystem + The component system of the process. + **kwargs : dict + Additional parameters for configuring the lumped rate model. + + Returns + ------- + LumpedRateModelWithPores + The configured lumped rate model. + """ + lrmp = LumpedRateModelWithPores( + component_system, name='LumpedRateModelWithPores' + ) + + lrmp.length = 0.014 + lrmp.diameter = 0.01 * 2 + lrmp.bed_porosity = 0.37 + lrmp.axial_dispersion = 5.75e-8 + + configure_solution_recorder(lrmp, **kwargs) + configure_discretization(lrmp, **kwargs) + configure_particles(lrmp) + configure_steric_mass_action(lrmp, component_system, **kwargs) + configure_film_diffusion(lrmp, component_system.n_comp) + configure_flow_direction(lrmp, **kwargs) + + return lrmp + + +def configure_multichannel_transport_model(component_system: ComponentSystem, **kwargs) -> MCT: + """ + Configure a multichannel transport model. + + Parameters + ---------- + component_system : ComponentSystem + The component system of the process. + **kwargs : dict + Additional parameters for configuring the multichannel transport model. + + Returns + ------- + MCT + The configured multichannel transport model. + """ + mct = MCT(component_system, nchannel=3, name='MCT') + + mct.length = 0.014 + mct.channel_cross_section_areas = 3 * [2 * np.pi * (0.01 ** 2)] + mct.axial_dispersion = 5.75e-8 + + n_comp: int = component_system.n_comp + + mct.exchange_matrix = np.array([ + [n_comp * [0.0], n_comp * [0.001], n_comp * [0.0]], + [n_comp * [0.002], n_comp * [0.0], n_comp * [0.003]], + [n_comp * [0.0], n_comp * [0.0], n_comp * [0.0]] + ]) + + configure_solution_recorder(mct, **kwargs) + configure_discretization(mct, **kwargs) + configure_flow_direction(mct, **kwargs) + + return mct + + +def configure_discretization(unit_operation, **kwargs) -> None: + """ + Configure the discretization settings for a unit operation. + + Parameters + ---------- + unit_operation : UnitOperation + The unit operation to be configured. + **kwargs : dict + Additional parameters for configuring the discretization. + """ + n_col: int = kwargs.get('n_col', 100) + n_par: int = kwargs.get('n_par', 2) + ad_jacobian: bool = kwargs.get('ad_jacobian', False) + + if 'npar' in unit_operation.discretization.parameters: + unit_operation.discretization.npar = n_par + unit_operation.discretization.par_disc_type = 'EQUIDISTANT_PAR' + + unit_operation.discretization.ncol = n_col + unit_operation.discretization.use_analytic_jacobian = not ad_jacobian + + +def configure_particles(unit_operation) -> None: + """ + Configure the particle settings for a unit operation. + + Parameters + ---------- + unit_operation : UnitOperation + The unit operation to be configured. + """ + par_radius = 4.5e-5 + par_porosity = 0.75 + + unit_operation.particle_radius = par_radius + unit_operation.particle_porosity = par_porosity + unit_operation.discretization.par_geom = 'SPHERE' + + +def configure_steric_mass_action(unit_operation, component_system, **kwargs) -> None: + """ + Configure the steric mass action binding model for a unit operation. + + Parameters + ---------- + unit_operation : UnitOperation + The unit operation to be configured. + component_system : ComponentSystem + The component system of the process. + **kwargs : dict + Additional parameters for configuring the steric mass action binding model. + """ + is_kinetic = kwargs.get('is_kinetic', True) + + kA = 35.5 + kD = 1000.0 + nu = 4.7 + sigma = 11.83 + sma_lambda = 1.2e3 + + binding_model = StericMassAction(component_system) + + binding_model.is_kinetic = is_kinetic + binding_model.n_binding_sites = 1 + binding_model.adsorption_rate = kA + binding_model.desorption_rate = kD + binding_model.characteristic_charge = nu + binding_model.steric_factor = sigma + binding_model.capacity = sma_lambda + + unit_operation.binding_model = binding_model + + +def configure_film_diffusion(unit_operation, n_comp) -> None: + """ + Configure the film diffusion settings for a unit operation. + + Parameters + ---------- + unit_operation : UnitOperation + The unit operation to be configured. + n_comp : int + The number of components in the process. + """ + unit_operation.film_diffusion = [6.9e-6] * n_comp + + +def configure_flow_direction(unit_operation, **kwargs) -> None: + """ + Configure the flow direction for a unit operation. + + Parameters + ---------- + unit_operation : UnitOperation + The unit operation to be configured. + **kwargs : dict + Additional parameters for configuring the flow direction. + """ + reverse_flow = kwargs.get('reverse_flow', False) + unit_operation.flow_direction = -1 if reverse_flow else 1 + + +def configure_solution_recorder(unit_operation, **kwargs) -> None: + """ + Configure the solution recorder for a unit operation. + + Parameters + ---------- + unit_operation : UnitOperation + The unit operation to be configured. + **kwargs : dict + Additional parameters for configuring the solution recorder. + """ + for write_solution, value in unit_operation.solution_recorder.parameters.items(): + if value is False: + unit_operation.solution_recorder.parameters[write_solution] = True diff --git a/tests/optimization_problem_fixtures.py b/tests/optimization_problem_fixtures.py index 8da45ef6..e8f05683 100644 --- a/tests/optimization_problem_fixtures.py +++ b/tests/optimization_problem_fixtures.py @@ -5,7 +5,9 @@ """ from functools import partial +from typing import NoReturn import warnings + import numpy as np from CADETProcess.optimization import OptimizationProblem, OptimizationResults @@ -31,45 +33,47 @@ "err_msg": error } + def allow_test_failure_percentage( test_function, test_kwargs, mismatch_tol=0.0 - ): - """When two arrays are compared, allow a certain fraction of the comparisons - to fail. This behaviour is explicitly accepted in convergence tests of - multi-objective tests. The reason behind it is that building the pareto - front takes time and removing dominated solutions from the frontier can - take a long time. Hence accepting a certain fraction of dominated-solutions - is acceptable, when the majority points lies on the pareto front. - - While of course for full convergence checks the mismatch tolerance should - be reduced to zero, for normal testing the fraction can be raised to - say 0.25. This value can be adapted for easy or difficult cases. - """ - assert 0.0 <= mismatch_tol <= 1.0, "mismatch_tol must be between 0 and 1." - try: - test_function(**test_kwargs) - except AssertionError as e: - msg = e.args[0].split("\n") - lnum, mismatch_line = [(i, l) for i, l in enumerate(msg) - if "Mismatched elements:" in l][0] - mismatch_percent = float(mismatch_line.split("(")[1].split("%")[0]) - if mismatch_percent / 100 > mismatch_tol: - err_line = ( - "---> " + mismatch_line + - f" exceeded tolerance ({mismatch_percent}% > {mismatch_tol * 100}%)" - ) - msg[lnum] = err_line - raise AssertionError("\n".join(msg)) - else: - warn_line = ( - mismatch_line + - f" below tolerance ({mismatch_percent}% <= {mismatch_tol * 100}%)" - ) - warnings.warn( - f"Equality test passed with {warn_line}" - ) + ): + """When two arrays are compared, allow a certain fraction of the comparisons + to fail. This behaviour is explicitly accepted in convergence tests of + multi-objective tests. The reason behind it is that building the pareto + front takes time and removing dominated solutions from the frontier can + take a long time. Hence accepting a certain fraction of dominated-solutions + is acceptable, when the majority points lies on the pareto front. + + While of course for full convergence checks the mismatch tolerance should + be reduced to zero, for normal testing the fraction can be raised to + say 0.25. This value can be adapted for easy or difficult cases. + """ + assert 0.0 <= mismatch_tol <= 1.0, "mismatch_tol must be between 0 and 1." + try: + test_function(**test_kwargs) + except AssertionError as e: + msg = e.args[0].split("\n") + lnum, mismatch_line = [(i, l) for i, l in enumerate(msg) + if "Mismatched elements:" in l][0] + mismatch_percent = float(mismatch_line.split("(")[1].split("%")[0]) + if mismatch_percent / 100 > mismatch_tol: + err_line = ( + "---> " + mismatch_line + + f" exceeded tolerance ({mismatch_percent}% > {mismatch_tol * 100}%)" + ) + msg[lnum] = err_line + raise AssertionError("\n".join(msg)) + else: + warn_line = ( + mismatch_line + + f" below tolerance ({mismatch_percent}% <= {mismatch_tol * 100}%)" + ) + warnings.warn( + f"Equality test passed with {warn_line}" + ) + class TestProblem(OptimizationProblem): @property @@ -134,8 +138,11 @@ def optimal_solution(self): def x0(self): return np.repeat(0.9, self.n_variables) - def test_if_solved(self, optimization_results: OptimizationResults, - test_kwargs=default_test_kwargs): + def test_if_solved( + self, + optimization_results: OptimizationResults, + test_kwargs=default_test_kwargs + ) -> NoReturn: x_true, f_true = self.optimal_solution x = optimization_results.x f = optimization_results.f @@ -145,7 +152,6 @@ def test_if_solved(self, optimization_results: OptimizationResults, np.testing.assert_allclose(x, x_true, **test_kwargs) - class LinearConstraintsSooTestProblem(TestProblem): def __init__(self, transform=None, has_evaluator=False, *args, **kwargs): self.test_abs_tol = 0.1 @@ -166,6 +172,7 @@ def setup_variables(self, transform): self.add_variable('var_0', lb=-2, ub=2, transform=transform) self.add_variable('var_1', lb=-2, ub=2, transform=transform) self.add_variable('var_2', lb=0, ub=2, transform="log") + def setup_linear_constraints(self): self.add_linear_constraint(['var_0', 'var_1'], [-1, -0.5], 0) @@ -201,7 +208,6 @@ def test_if_solved(self, optimization_results: OptimizationResults, np.testing.assert_allclose(x, x_true, **test_kwargs) - class NonlinearConstraintsSooTestProblem(TestProblem): def __init__(self, transform=None, has_evaluator=False, *args, **kwargs): self.fixture_evaluator = None @@ -313,10 +319,13 @@ def x0(self): def optimal_solution(self): x = np.array([-5.0, 5.0, 0.0]).reshape(1, self.n_variables) f = -15.0 + return x, f - def test_if_solved(self, optimization_results: OptimizationResults, - test_kwargs=default_test_kwargs): + def test_if_solved( + self, optimization_results: OptimizationResults, + test_kwargs=default_test_kwargs + ): x_true, f_true = self.optimal_solution x = optimization_results.x f = optimization_results.f @@ -326,15 +335,12 @@ def test_if_solved(self, optimization_results: OptimizationResults, np.testing.assert_allclose(x, x_true, **test_kwargs) - - - class LinearEqualityConstraintsSooTestProblem(TestProblem): def __init__( self, transform=None, *args, **kwargs - ): + ): super().__init__( "linear_equality_constraints_single_objective", *args, **kwargs @@ -368,8 +374,10 @@ def optimal_solution(self): return x.reshape(1, self.n_variables), f - def test_if_solved(self, optimization_results: OptimizationResults, - test_kwargs=default_test_kwargs): + def test_if_solved( + self, optimization_results: OptimizationResults, + test_kwargs=default_test_kwargs + ): x_true, f_true = self.optimal_solution x = optimization_results.x f = optimization_results.f @@ -409,6 +417,7 @@ def _objective_function(self, x): def x0(self): return [-0.5, 1.5] + @property def optimal_solution(self): x = np.array([-1, 2]).reshape(1, self.n_variables) f = -3 @@ -417,7 +426,7 @@ def optimal_solution(self): def test_if_solved(self, optimization_results: OptimizationResults, test_kwargs=default_test_kwargs): - x_true, f_true = self.optimal_solution() + x_true, f_true = self.optimal_solution x = optimization_results.x f = optimization_results.f @@ -500,8 +509,10 @@ def optimal_solution(self): return X, F - def test_if_solved(self, optimization_results: OptimizationResults, - test_kwargs=default_test_kwargs): + def test_if_solved( + self, optimization_results: OptimizationResults, + test_kwargs=default_test_kwargs + ) -> NoReturn: X = optimization_results.x x1, x2 = X.T @@ -580,7 +591,6 @@ def find_corresponding_x2(self, x1): """ return np.where(x1 <= 2, 2 - x1, 0) - @property def x0(self): return [1.6, 1.4] @@ -595,8 +605,10 @@ def optimal_solution(self): return X, F - def test_if_solved(self, optimization_results: OptimizationResults, - test_kwargs=default_test_kwargs): + def test_if_solved( + self, optimization_results: OptimizationResults, + test_kwargs=default_test_kwargs + ): X = optimization_results.x x1, x2 = X.T @@ -667,8 +679,11 @@ def optimal_solution(self): return X, F # G ??? - def test_if_solved(self, optimization_results: OptimizationResults, - test_kwargs=default_test_kwargs): + def test_if_solved( + self, + optimization_results: OptimizationResults, + test_kwargs=default_test_kwargs + ) -> NoReturn: X = optimization_results.x_transformed x1, x2 = X.T diff --git a/tests/test_binding.py b/tests/test_binding.py index 63e1ef12..ae0f9a4f 100644 --- a/tests/test_binding.py +++ b/tests/test_binding.py @@ -4,7 +4,7 @@ from CADETProcess.processModel import ComponentSystem, binding, BindingBaseClass from CADETProcess.processModel import ( - Langmuir, BiLangmuir, MultistateStericMassAction, StericMassAction + Langmuir, BiLangmuir, MultistateStericMassAction, StericMassAction, MobilePhaseModulator ) from CADETProcess.simulator.cadetAdapter import adsorption_parameters_map @@ -51,6 +51,16 @@ def setUp(self): binding_model.reference_solid_phase_conc = 100 self.sma = binding_model + binding_model = MobilePhaseModulator(component_system, name='test') + + binding_model.adsorption_rate = [0.02, 0.03] + binding_model.desorption_rate = [1, 1] + binding_model.capacity = [100, 100] + binding_model.ion_exchange_characteristic = [1.4, 1.7] + binding_model.hydrophobicity = [1.4, 1.7] + binding_model.linear_threshold = 1e-8 + self.mobile_phase_modulator = binding_model + def test_sma_reference_concentration_transformations(self): adsorption_rate = numpy.array(self.sma.adsorption_rate) desorption_rate = numpy.array(self.sma.desorption_rate) diff --git a/tests/test_cadet_adapter.py b/tests/test_cadet_adapter.py index b1ac69b1..78df4c9e 100644 --- a/tests/test_cadet_adapter.py +++ b/tests/test_cadet_adapter.py @@ -2,25 +2,35 @@ import platform import shutil import unittest +import warnings +from typing import Optional +import pytest +import numpy as np +import numpy.testing as npt +from itertools import product -from CADETProcess.simulator import Cadet +from tests.create_LWE import create_lwe +from CADETProcess import CADETProcessError +from CADETProcess.processModel import Process +from CADETProcess import SimulationResults +from CADETProcess.simulator import Cadet +from CADETProcess.processModel.discretization import NoDiscretization -def detect_cadet(): - """TODO: Consider moving to Cadet module.""" - executable = 'cadet-cli' - if platform.system() == 'Windows': - executable += '.exe' - cli_path = Path(shutil.which(executable)) - found_cadet = False - if cli_path.is_file(): +def detect_cadet(install_path: Optional[Path] = None): + try: + simulator = Cadet(install_path) found_cadet = True - install_path = cli_path.parent.parent - return found_cadet, cli_path, install_path + install_path = simulator.install_path + except FileNotFoundError: + found_cadet = False + + return found_cadet, install_path -found_cadet, cli_path, install_path = detect_cadet() +install_path = None +found_cadet, install_path = detect_cadet() class Test_Adapter(unittest.TestCase): @@ -28,47 +38,623 @@ class Test_Adapter(unittest.TestCase): def __init__(self, methodName='runTest'): super().__init__(methodName) - @unittest.skipIf(found_cadet is False, "Skip if CADET is not installed.") - def test_install_path(self): - simulator = Cadet() - self.assertEqual(cli_path, simulator.cadet_cli_path) - - with self.assertWarns(UserWarning): - simulator.install_path = cli_path - self.assertEqual(cli_path, simulator.cadet_cli_path) - - simulator.install_path = cli_path.parent.parent - self.assertEqual(cli_path, simulator.cadet_cli_path) - - simulator = Cadet(install_path) - self.assertEqual(cli_path, simulator.cadet_cli_path) - - with self.assertRaises(FileNotFoundError): - simulator = Cadet('foo/bar') - @unittest.skipIf(found_cadet is False, "Skip if CADET is not installed.") def test_check_cadet(self): - simulator = Cadet() + simulator = Cadet(install_path) self.assertTrue(simulator.check_cadet()) file_name = simulator.get_tempfile_name() cwd = simulator.temp_dir - sim = simulator.create_lwe(cwd / file_name) + sim = simulator.get_new_cadet_instance() + sim.create_lwe(cwd / file_name) sim.run() @unittest.skipIf(found_cadet is False, "Skip if CADET is not installed.") def test_create_lwe(self): - simulator = Cadet() + simulator = Cadet(install_path) file_name = simulator.get_tempfile_name() cwd = simulator.temp_dir - sim = simulator.create_lwe(cwd / file_name) + sim = simulator.get_new_cadet_instance() + sim.create_lwe(cwd / file_name) sim.run() def tearDown(self): shutil.rmtree('./tmp', ignore_errors=True) -if __name__ == '__main__': - unittest.main() +unit_types = [ + 'Cstr', 'GeneralRateModel', 'TubularReactor', + 'LumpedRateModelWithoutPores', 'LumpedRateModelWithPores', 'MCT' +] + +parameter_combinations = [ + {}, # Default parameters + {"n_par": 1}, + {"n_col": 1}, +] + +# Parameters to skip for specific unit types +exclude_rules = { + 'Cstr': [{"n_col": 1}, {"n_par": 1}], + 'TubularReactor': [{"n_par": 1}], + 'LumpedRateModelWithoutPores': [{"n_par": 1}], + 'LumpedRateModelWithPores': [{"n_par": 1}], + 'MCT': [{"n_par": 1}], + +} + +test_cases = [ + pytest.param( + (unit_type, params), + id=f"{unit_type}-{'default' if not params else '-'.join(f'{k}={v}' for k, v in params.items())}" + ) + for unit_type in unit_types + for params in parameter_combinations + if not (unit_type in exclude_rules and params in exclude_rules[unit_type]) +] + +def run_simulation( + process: Process, + install_path: Optional[str] = None + ) -> SimulationResults: + """ + Run the CADET simulation for the given process and handle potential issues. + + Parameters + ---------- + process : Process + The process to simulate. + install_path : str, optional + The path to the CADET installation. + + Returns + ------- + SimulationResults + The results of the simulation. + + Raises + ------ + CADETProcessError + If the simulation fails with an error. + """ + try: + process_simulator = Cadet(install_path) + simulation_results = process_simulator.simulate(process) + + if not simulation_results.exit_flag == 0: + raise CADETProcessError( + f"LWE simulation failed with {simulation_results.exit_message}." + ) + + return simulation_results + + except Exception as e: + raise CADETProcessError(f"CADET simulation failed: {e}.") from e + + +@pytest.fixture() +def process(request: pytest.FixtureRequest): + """ + Fixture to set up the process for each unit type without running the simulation. + """ + unit_type, kwargs = request.param + process = create_lwe(unit_type, **kwargs) + return process + + +@pytest.fixture +def simulation_results(request: pytest.FixtureRequest): + """ + Fixture to set up the simulation for each unit type. + """ + unit_type, kwargs = request.param + process = create_lwe(unit_type, **kwargs) + simulation_results = run_simulation(process, install_path) + return simulation_results + +@pytest.mark.parametrize("process", test_cases, indirect=True) +class TestProcessWithLWE: + + def return_process_config(self, process: Process) -> dict: + """ + Returns the process configuration. + + Parameters + ---------- + process : Process + The process object. + + Returns + ------- + dict + The configuration of the process. + """ + process_simulator = Cadet(install_path) + process_config = process_simulator.get_process_config(process).input + return process_config + + def test_model_config(self, process: Process): + """ + Test the model configuration for various unit types in the process. + + Parameters + ---------- + process : Process + The process object. + """ + process_config = self.return_process_config(process) + + n_comp = process.component_system.n_comp + unit = process.flow_sheet.units[1] + + model_config = process_config.model + input_config = model_config.unit_000 + output_config = model_config.unit_002 + unit_config = model_config.unit_001 + + # ASSERT INPUT CONFIGURATION + c1_lwe = [[50.0], [0.0], [[100.0, 0.2]]] + cx_lwe = [[1.0], [0.0], [0.0]] + + expected_input_config = { + 'UNIT_TYPE': 'INLET', + 'NCOMP': n_comp, + 'INLET_TYPE': 'PIECEWISE_CUBIC_POLY', + 'discretization': { + 'nbound': n_comp * [0] + }, + 'sec_000': { + 'const_coeff': np.array(c1_lwe[0] + cx_lwe[0] * (n_comp - 1)), + 'lin_coeff': np.array([0.] * n_comp), + 'quad_coeff': np.array([0.] * n_comp), + 'cube_coeff': np.array([0.] * n_comp) + }, + 'sec_001': { + 'const_coeff': np.array(c1_lwe[1] + cx_lwe[1] * (n_comp - 1)), + 'lin_coeff': np.array([0.] * n_comp), + 'quad_coeff': np.array([0.] * n_comp), + 'cube_coeff': np.array([0.] * n_comp) + }, + 'sec_002': { + 'const_coeff': np.array([c1_lwe[2][0][0]] + cx_lwe[2] * (n_comp - 1)), + 'lin_coeff': np.array([c1_lwe[2][0][1]] + cx_lwe[2] * (n_comp - 1)), + 'quad_coeff': np.array([0.] * n_comp), + 'cube_coeff': np.array([0.] * n_comp) + } + } + + npt.assert_equal(input_config, expected_input_config) + + # ASSERT OUTPUT CONFIGURATION + expected_output_config = { + 'UNIT_TYPE': 'OUTLET', + 'NCOMP': n_comp, + 'discretization': { + 'nbound': n_comp * [0] + } + } + + npt.assert_equal(output_config, expected_output_config) + + # ASSERT MODEL CONFIGURATION + assert unit_config.NCOMP == n_comp + assert model_config.nunits == 3 + + if unit.name == 'Cstr': + self.check_cstr(unit, unit_config) + elif unit.name == 'GeneralRateModel': + self.check_general_rate_model(unit, unit_config) + elif unit.name == 'TubularReactor': + self.check_tubular_reactor(unit, unit_config) + elif unit.name == 'LumpedRateModelWithoutPores': + self.check_lumped_rate_model_without_pores(unit, unit_config) + elif unit.name == 'LumpedRateModelWithPores': + self.check_lumped_rate_model_with_pores(unit, unit_config) + elif unit.name == 'MCT': + self.check_mct(unit, unit_config) + + def check_cstr(self, unit, unit_config): + """ + Check the configuration for a CSTR unit. + + Parameters + ---------- + unit : Unit + The unit object. + unit_config : dict + The configuration of the unit. + """ + n_comp = unit.component_system.n_comp + + assert unit_config.UNIT_TYPE == 'CSTR' + assert unit_config.INIT_Q == n_comp * [0] + assert unit_config.INIT_C == n_comp * [0] + assert unit_config.INIT_LIQUID_VOLUME == 0.0008425 + assert unit_config.CONST_SOLID_VOLUME == 0.00015749999999999998 + assert unit_config.FLOWRATE_FILTER == 0.0 + assert unit_config.nbound == [1, 1, 1, 1] + + self.check_adsorption_config(unit, unit_config) + + def check_general_rate_model(self, unit, unit_config): + """ + Check the configuration for a General Rate Model unit. + + Parameters + ---------- + unit : Unit + The unit object. + unit_config : dict + The configuration of the unit. + """ + n_comp = unit.component_system.n_comp + + assert unit_config.UNIT_TYPE == 'GENERAL_RATE_MODEL' + assert unit_config.INIT_Q == n_comp * [0] + assert unit_config.INIT_C == n_comp * [0] + assert unit_config.INIT_CP == n_comp * [0] + assert unit_config.VELOCITY == unit.flow_direction + assert unit_config.COL_DISPERSION == 5.75e-08 + assert unit_config.CROSS_SECTION_AREA == np.pi * 0.01 ** 2 + assert unit_config.COL_LENGTH == 0.014 + assert unit_config.COL_POROSITY == 0.37 + assert unit_config.FILM_DIFFUSION == [6.9e-6] * n_comp + + self.check_particle_config(unit_config) + self.check_adsorption_config(unit, unit_config) + self.check_discretization(unit, unit_config) + + def check_tubular_reactor(self, unit, unit_config): + """ + Check the configuration for a Tubular Reactor unit. + + Parameters + ---------- + unit : Unit + The unit object. + unit_config : dict + The configuration of the unit. + """ + n_comp = unit.component_system.n_comp + + assert unit_config.UNIT_TYPE == 'LUMPED_RATE_MODEL_WITHOUT_PORES' + assert unit_config.INIT_C == n_comp * [0] + assert unit_config.VELOCITY == unit.flow_direction + assert unit_config.COL_DISPERSION == 5.75e-08 + assert unit_config.CROSS_SECTION_AREA == np.pi * 0.01 ** 2 + assert unit_config.COL_LENGTH == 0.014 + assert unit_config.TOTAL_POROSITY == 1 + + self.check_discretization(unit, unit_config) + + def check_lumped_rate_model_without_pores(self, unit, unit_config): + """ + Check the configuration for a Lumped Rate Model Without Pores unit. + + Parameters + ---------- + unit : Unit + The unit object. + unit_config : dict + The configuration of the unit. + """ + n_comp = unit.component_system.n_comp + + assert unit_config.UNIT_TYPE == 'LUMPED_RATE_MODEL_WITHOUT_PORES' + assert unit_config.INIT_C == n_comp * [0] + assert unit_config.VELOCITY == unit.flow_direction + assert unit_config.COL_DISPERSION == 5.75e-08 + assert unit_config.CROSS_SECTION_AREA == np.pi * 0.01 ** 2 + assert unit_config.COL_LENGTH == 0.014 + assert unit_config.TOTAL_POROSITY == 0.8425 + + self.check_adsorption_config(unit, unit_config) + self.check_discretization(unit, unit_config) + + def check_lumped_rate_model_with_pores(self, unit, unit_config): + """ + Check the configuration for a Lumped Rate Model With Pores unit. + + Parameters + ---------- + unit : Unit + The unit object. + unit_config : dict + The configuration of the unit. + """ + n_comp = unit.component_system.n_comp + + assert unit_config.UNIT_TYPE == 'LUMPED_RATE_MODEL_WITH_PORES' + assert unit_config.INIT_C == n_comp * [0] + assert unit_config.INIT_Q == n_comp * [0] + assert unit_config.INIT_CP == n_comp * [0] + assert unit_config.VELOCITY == unit.flow_direction + assert unit_config.COL_DISPERSION == 5.75e-08 + assert unit_config.CROSS_SECTION_AREA == np.pi * 0.01 ** 2 + assert unit_config.COL_LENGTH == 0.014 + assert unit_config.COL_POROSITY == 0.37 + assert unit_config.FILM_DIFFUSION == [6.9e-6] * n_comp + + self.check_adsorption_config(unit, unit_config) + self.check_discretization(unit, unit_config) + self.check_particle_config(unit_config) + + def check_mct(self, unit, unit_config): + """ + Check the configuration for a Multi-Channel Transport unit. + + Parameters + ---------- + unit : Unit + The unit object. + unit_config : dict + The configuration of the unit. + """ + n_comp = unit.component_system.n_comp + n_channel = unit.nchannel + + assert unit_config.UNIT_TYPE == 'MULTI_CHANNEL_TRANSPORT' + npt.assert_equal(unit_config.INIT_C, n_comp * [(n_channel * [0])]) + assert unit_config.VELOCITY == unit.flow_direction + npt.assert_equal( + unit_config.EXCHANGE_MATRIX, + np.array([ + [n_comp * [0.0], n_comp * [0.001], n_comp * [0.0]], + [n_comp * [0.002], n_comp * [0.0], n_comp * [0.003]], + [n_comp * [0.0], n_comp * [0.0], n_comp * [0.0]] + ]) + ) + assert unit_config.COL_DISPERSION == 5.75e-08 + assert unit_config.NCHANNEL == 3 + assert unit_config.CHANNEL_CROSS_SECTION_AREAS == 3 * [2 * np.pi * (0.01 ** 2)] + + self.check_discretization(unit, unit_config) + + def check_adsorption_config(self, unit, unit_config): + """ + Check the adsorption configuration. + + Parameters + ---------- + unit : Unit + The unit object. + unit_config : dict + The configuration of the unit. + """ + n_comp = unit.component_system.n_comp + + expected_adsorption_config = { + 'ADSORPTION_MODEL': 'STERIC_MASS_ACTION', + 'IS_KINETIC': True, + 'SMA_KA': n_comp * [35.5], + 'SMA_KD': n_comp * [1000.0], + 'SMA_LAMBDA': 1200.0, + 'SMA_NU': n_comp * [4.7], + 'SMA_SIGMA': n_comp * [11.83], + 'SMA_REFC0': 1.0, + 'SMA_REFQ': 1.0 + } + + assert unit_config.adsorption_model == 'STERIC_MASS_ACTION' + npt.assert_equal(unit_config.adsorption, expected_adsorption_config) + + def check_particle_config(self, unit_config): + """ + Check the particle configuration. + + Parameters + ---------- + unit_config : dict + The configuration of the unit. + """ + assert unit_config.PAR_POROSITY == 0.75 + assert unit_config.PAR_RADIUS == 4.5e-05 + assert unit_config.discretization.par_geom == 'SPHERE' + + def check_discretization(self, unit, unit_config): + """ + Check the discretization configuration. + + Parameters + ---------- + unit : Unit + The unit object. + unit_config : dict + The configuration of the unit. + """ + assert unit_config.discretization.ncol == unit.discretization.ncol + assert unit_config.discretization.use_analytic_jacobian == unit.discretization.use_analytic_jacobian + assert unit_config.discretization.reconstruction == 'WENO' + + npt.assert_equal( + unit_config.discretization.weno, { + 'boundary_model': 0, + 'weno_eps': 1e-10, + 'weno_order': 3 + } + ) + + npt.assert_equal( + unit_config.discretization.consistency_solver, { + 'solver_name': 'LEVMAR', + 'init_damping': 0.01, + 'min_damping': 0.0001, + 'max_iterations': 50, + 'subsolvers': 'LEVMAR' + } + ) + + if 'spatial_method' in unit.discretization.parameters: + assert unit_config.discretization.spatial_method == unit.discretization.spatial_method + + if 'npar' in unit.discretization.parameters: + assert unit_config.discretization.npar == unit.discretization.npar + assert unit_config.discretization.par_disc_type == 'EQUIDISTANT_PAR' + + def test_solver_config(self, process: Process): + """ + Test the solver configuration for the process. + + Parameters + ---------- + process : Process + The process object. + """ + process_config = self.return_process_config(process) + solver_config = process_config.solver + + expected_solver_config = { + 'nthreads': 1, + 'consistent_init_mode': 1, + 'consistent_init_mode_sens': 1, + 'user_solution_times': np.arange(0.0, 120 * 60 + 1), + 'sections': { + 'nsec': 3, + 'section_times': [0.0, 10.0, 90.0, 7200.0], + 'section_continuity': [0, 0] + }, + 'time_integrator': { + 'abstol': 1e-08, + 'algtol': 1e-12, + 'reltol': 1e-06, + 'reltol_sens': 1e-12, + 'init_step_size': 1e-06, + 'max_steps': 1000000, + 'max_step_size': 0.0, + 'errortest_sens': False, + 'max_newton_iter': 1000000, + 'max_errtest_fail': 1000000, + 'max_convtest_fail': 1000000, + 'max_newton_iter_sens': 1000000 + } + } + + npt.assert_equal(solver_config, expected_solver_config) + + def test_return_config(self, process: Process): + """ + Test the return configuration for the process. + + Parameters + ---------- + process : Process + The process object. + """ + process_config = self.return_process_config(process) + return_config = process_config['return'] + + # Assert that all values in return_config.unit_001 (model unit operation) are True + for key, value in return_config['unit_001'].items(): + assert value, f"The value for key '{key}' is not True. Found: {value}" + + def test_sensitivity_config(self, process: Process): + """ + Test the sensitivity configuration for the process. + + Parameters + ---------- + process : Process + The process object. + """ + process_config = self.return_process_config(process) + sensitivity_config = process_config.sensitivity + + expected_sensitivity_config = {'sens_method': 'ad1', 'nsens': 0} + npt.assert_equal(sensitivity_config, expected_sensitivity_config) + + +@pytest.mark.parametrize("simulation_results", test_cases, indirect=True) +class TestResultsWithLWE: + def test_trigger_simulation(self, simulation_results): + """ + Test to trigger the simulation. + """ + simulation_results = simulation_results + assert simulation_results is not None + + def test_compare_solution_shape(self, simulation_results): + """ + Compare the dimensions of the solution object against the expected solution shape. + """ + simulation_results = simulation_results + process = simulation_results.process + unit = process.flow_sheet.units[1] + + # for units without ports + if not unit.has_ports: + # assert solution inlet has shape (t, n_comp) + assert simulation_results.solution[unit.name].inlet.solution_shape == ( + int(process.cycle_time+1), process.component_system.n_comp + ) + # assert solution outlet has shape (t, n_comp) + assert simulation_results.solution[unit.name].outlet.solution_shape == ( + int(process.cycle_time+1), process.component_system.n_comp + ) + # assert solution bulk has shape (t, n_col, n_comp) + if not isinstance(unit.discretization, NoDiscretization): + assert simulation_results.solution[unit.name].bulk.solution_shape == ( + int(process.cycle_time+1), + unit.discretization.ncol, + process.component_system.n_comp + ) + + # for units with ports + else: + # assert solution inlet is given for each port + assert len(simulation_results.solution[unit.name].inlet) == unit.n_ports + # assert solution for channel 0 has shape (t, n_comp) + assert simulation_results.solution[unit.name].inlet.channel_0.solution_shape == ( + int(process.cycle_time+1), process.component_system.n_comp + ) + # assert solution bulk has shape (t, n_col, n_ports, n_comp) + assert simulation_results.solution[unit.name].bulk.solution_shape == ( + int(process.cycle_time+1), + unit.discretization.ncol, + unit.n_ports, + process.component_system.n_comp + ) + + # for units with particles + if unit.supports_binding and not isinstance(unit.discretization, NoDiscretization): + # for units with solid phase and particle discretization + if 'npar' in unit.discretization.parameters: + # assert solution solid has shape (t, n_col, n_par, n_comp) + assert simulation_results.solution[unit.name].solid.solution_shape == ( + int(process.cycle_time+1), + unit.discretization.ncol, + unit.discretization.npar, + process.component_system.n_comp + ) + # for units with solid phase and without particle discretization + else: + # assert solution solid has shape (t, ncol, n_comp) + assert simulation_results.solution[unit.name].solid.solution_shape == ( + int(process.cycle_time+1), + unit.discretization.ncol, + process.component_system.n_comp + ) + + # for units with particle mobile phase and particle discretization + if unit.supports_particle_reaction and unit.name != "LumpedRateModelWithoutPores": + # assert soluction particle has shape (t, n_col, n_par, n_comp) + if 'npar' in unit.discretization.parameters: + assert simulation_results.solution[unit.name].particle.solution_shape == ( + int(process.cycle_time+1), + unit.discretization.ncol, + unit.discretization.npar, + process.component_system.n_comp + ) + # for units with particle mobiles phase and without particle discretization + else: + # assert solution particle has shape (t, n_col, n_comp) + assert simulation_results.solution[unit.name].particle.solution_shape == ( + int(process.cycle_time+1), + unit.discretization.ncol, + process.component_system.n_comp + ) + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/test_cadet_reactions.py b/tests/test_cadet_reactions.py index 241016b1..380cc5f6 100644 --- a/tests/test_cadet_reactions.py +++ b/tests/test_cadet_reactions.py @@ -16,7 +16,7 @@ from CADETProcess.simulator import Cadet -from test_cadet_adapter import found_cadet +from tests.test_cadet_adapter import found_cadet def setup_process(unit_type): @@ -28,8 +28,10 @@ def setup_process(unit_type): if unit_type == 'cstr': cstr = Cstr(component_system, 'reaction_unit') - cstr.V = 1e-3 - cstr.porosity = 0.7 + total_volume = 1e-3 + total_porosity = 0.7 + cstr.init_liquid_volume = total_porosity * total_volume + cstr.const_solid_volume = (1 - total_porosity) * total_volume unit = cstr elif unit_type == 'pfr': diff --git a/tests/test_carousel.py b/tests/test_carousel.py index 2a6ad922..6e643ad9 100644 --- a/tests/test_carousel.py +++ b/tests/test_carousel.py @@ -526,19 +526,19 @@ def test_flow_rates(self): column_0 = process.flow_rate_timelines['column_0'] column_1 = process.flow_rate_timelines['column_1'] - flow_rate = serial_inlet.total_in.value(0) + flow_rate = serial_inlet.total_in[None].value(0) flow_rate_expected = 2e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) - flow_rate = serial_outlet.total_in.value(0) + flow_rate = serial_outlet.total_in[None].value(0) flow_rate_expected = 2e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) - flow_rate = column_0.total_in.value(0) + flow_rate = column_0.total_in[None].value(0) flow_rate_expected = 2e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) - flow_rate = column_1.total_in.value(0) + flow_rate = column_1.total_in[None].value(0) flow_rate_expected = 2e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) @@ -550,19 +550,19 @@ def test_flow_rates(self): column_0 = process.flow_rate_timelines['column_0'] column_1 = process.flow_rate_timelines['column_1'] - flow_rate = parallel_inlet.total_in.value(0) + flow_rate = parallel_inlet.total_in[None].value(0) flow_rate_expected = 2e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) - flow_rate = parallel_inlet.total_in.value(0) + flow_rate = parallel_inlet.total_in[None].value(0) flow_rate_expected = 2e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) - flow_rate = column_0 .total_in.value(0) + flow_rate = column_0 .total_in[None].value(0) flow_rate_expected = 1e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) - flow_rate = column_1.total_in.value(0) + flow_rate = column_1.total_in[None].value(0) flow_rate_expected = 1e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) @@ -575,31 +575,31 @@ def test_flow_rates(self): column_0 = process.flow_rate_timelines['column_0'] column_2 = process.flow_rate_timelines['column_2'] - flow_rate = serial_inlet.total_in.value(0) + flow_rate = serial_inlet.total_in[None].value(0) flow_rate_expected = 2e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) - flow_rate = parallel_inlet.total_in.value(0) + flow_rate = parallel_inlet.total_in[None].value(0) flow_rate_expected = 3e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) # Initial state t = 0 - flow_rate = column_0.total_in.value(t) + flow_rate = column_0.total_in[None].value(t) flow_rate_expected = 2e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) - flow_rate = column_2.total_in.value(t) + flow_rate = column_2.total_in[None].value(t) flow_rate_expected = 1.5e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) # First position t = builder.switch_time - flow_rate = column_0.total_in.value(t) + flow_rate = column_0.total_in[None].value(t) flow_rate_expected = 1.5e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) - flow_rate = column_2.total_in.value(t) + flow_rate = column_2.total_in[None].value(t) flow_rate_expected = 2e-7 np.testing.assert_almost_equal(flow_rate, flow_rate_expected) diff --git a/tests/test_compartment.py b/tests/test_compartment.py index de5e6120..62a4074e 100644 --- a/tests/test_compartment.py +++ b/tests/test_compartment.py @@ -66,44 +66,132 @@ def test_complex(self): def test_connections(self): flow_rates_expected = { 'compartment_0': { - 'total_in': np.array([1., 0., 0., 0.]), - 'total_out': np.array([1., 0., 0., 0.]), + 'total_in': { + None: np.array([1., 0., 0., 0.]) + }, + 'total_out': { + None: np.array([1., 0., 0., 0.]) + }, 'origins': { - 'compartment_1': np.array([0.1, 0., 0., 0.]), - 'compartment_2': np.array([0.2, 0., 0., 0.]), - 'compartment_3': np.array([0.3, 0., 0., 0.]), - 'compartment_4': np.array([0.4, 0., 0., 0.]) + None: { + 'compartment_1': { + None: np.array([0.1, 0., 0., 0.]) + }, + 'compartment_2': { + None: np.array([0.2, 0., 0., 0.]) + }, + 'compartment_3': { + None: np.array([0.3, 0., 0., 0.]) + }, + 'compartment_4': { + None: np.array([0.4, 0., 0., 0.]) + } + } }, 'destinations': { - 'compartment_1': np.array([0.1, 0., 0., 0.]), - 'compartment_2': np.array([0.2, 0., 0., 0.]), - 'compartment_3': np.array([0.3, 0., 0., 0.]), - 'compartment_4': np.array([0.4, 0., 0., 0.]) + None: { + 'compartment_1': { + None: np.array([0.1, 0., 0., 0.]) + }, + 'compartment_2': { + None: np.array([0.2, 0., 0., 0.]) + }, + 'compartment_3': { + None: np.array([0.3, 0., 0., 0.]) + }, + 'compartment_4': { + None: np.array([0.4, 0., 0., 0.]) + } + } }, }, 'compartment_1': { - 'total_in': np.array([0.1, 0., 0., 0.]), - 'total_out': np.array([0.1, 0., 0., 0.]), - 'origins': {'compartment_0': np.array([0.1, 0., 0., 0.])}, - 'destinations': {'compartment_0': np.array([0.1, 0., 0., 0.])}, + 'total_in': { + None: np.array([0.1, 0., 0., 0.]) + }, + 'total_out': { + None: np.array([0.1, 0., 0., 0.]) + }, + 'origins': { + None: { + 'compartment_0': { + None: np.array([0.1, 0., 0., 0.]) + } + } + }, + 'destinations': { + None: { + 'compartment_0': { + None: np.array([0.1, 0., 0., 0.]) + } + } + }, }, 'compartment_2': { - 'total_in': np.array([0.2, 0., 0., 0.]), - 'total_out': np.array([0.2, 0., 0., 0.]), - 'origins': {'compartment_0': np.array([0.2, 0., 0., 0.])}, - 'destinations': {'compartment_0': np.array([0.2, 0., 0., 0.])}, + 'total_in': { + None: np.array([0.2, 0., 0., 0.]) + }, + 'total_out': { + None: np.array([0.2, 0., 0., 0.]) + }, + 'origins': { + None: { + 'compartment_0': { + None: np.array([0.2, 0., 0., 0.]) + } + } + }, + 'destinations': { + None: { + 'compartment_0': { + None: np.array([0.2, 0., 0., 0.]) + } + } + } }, 'compartment_3': { - 'total_in': np.array([0.3, 0., 0., 0.]), - 'total_out': np.array([0.3, 0., 0., 0.]), - 'origins': {'compartment_0': np.array([0.3, 0., 0., 0.])}, - 'destinations': {'compartment_0': np.array([0.3, 0., 0., 0.])}, + 'total_in': { + None: np.array([0.3, 0., 0., 0.]) + }, + 'total_out': { + None: np.array([0.3, 0., 0., 0.]) + }, + 'origins': { + None: { + 'compartment_0': { + None: np.array([0.3, 0., 0., 0.]) + } + } + }, + 'destinations': { + None: { + 'compartment_0': { + None: np.array([0.3, 0., 0., 0.]) + } + } + } }, 'compartment_4': { - 'total_in': np.array([0.4, 0., 0., 0.]), - 'total_out': np.array([0.4, 0., 0., 0.]), - 'origins': {'compartment_0': np.array([0.4, 0., 0., 0.])}, - 'destinations': {'compartment_0': np.array([0.4, 0., 0., 0.])}, + 'total_in': { + None: np.array([0.4, 0., 0., 0.]) + }, + 'total_out': { + None: np.array([0.4, 0., 0., 0.]) + }, + 'origins': { + None: { + 'compartment_0': { + None: np.array([0.4, 0., 0., 0.]) + } + } + }, + 'destinations': { + None: { + 'compartment_0': { + None: np.array([0.4, 0., 0., 0.]) + } + } + } } } flow_rates = self.builder_simple.flow_sheet.get_flow_rates().to_dict() diff --git a/tests/test_components.py b/tests/test_components.py index 3885efae..7c198325 100644 --- a/tests/test_components.py +++ b/tests/test_components.py @@ -1,3 +1,4 @@ + import unittest import numpy as np @@ -34,6 +35,20 @@ def setUp(self): self.component_system_4 = ComponentSystem(2) self.component_system_4.add_component('manual_label') + self.component_system_5 = ComponentSystem() + self.component_system_5.add_component( + 'A', + species=['A+', 'A-'], + molecular_weight=[1, 0] + ) + + self.component_system_6 = ComponentSystem() + self.component_system_6.add_component( + 'A', + species=['A+', 'A-'], + density=[1, 0] + ) + def test_names(self): names_expected = ['0', '1'] names = self.component_system_0.names @@ -113,6 +128,15 @@ def test_charge(self): charges = self.component_system_3.charges np.testing.assert_equal(charges_expected, charges) + def test_molecular_weights(self): + molecular_weights_expected = [1, 0] + molecular_weights = self.component_system_5.molecular_weights + np.testing.assert_equal(molecular_weights_expected, molecular_weights) + + def test_densities(self): + densities_expected = [1, 0] + densities = self.component_system_6.densities + np.testing.assert_equal(densities_expected, densities) if __name__ == '__main__': unittest.main() diff --git a/tests/test_difference.py b/tests/test_difference.py index fe7e79ae..6a61b6d6 100644 --- a/tests/test_difference.py +++ b/tests/test_difference.py @@ -6,6 +6,7 @@ from CADETProcess import CADETProcessError from CADETProcess.processModel import ComponentSystem from CADETProcess.reference import ReferenceIO +from CADETProcess.solution import SolutionIO comp_2 = ComponentSystem(['A', 'B']) @@ -34,6 +35,8 @@ solution_2_gaussian_different_height[:, 1] = stats.norm.pdf(time, mu_0, sigma_0) solution_2_gaussian_different_height[:, 0] = stats.norm.pdf(time, mu_2, sigma_2) +q_const = np.ones(time.shape) + from CADETProcess.comparison import SSE class TestSSE(unittest.TestCase): @@ -387,5 +390,50 @@ def test_metric(self): metrics = difference.evaluate(self.reference) +from CADETProcess.fractionation import Fraction +from CADETProcess.reference import FractionationReference +from CADETProcess.comparison import FractionationSSE + + +class TestFractionation(unittest.TestCase): + def __init__(self, methodName='runTest'): + super().__init__(methodName) + + def setUp(self): + fraction_1 = Fraction( + start=15, + end=30, + mass=[0.49865015, 0.02274985], + volume=15, + ) + fraction_2 = Fraction( + start=30, + end=45, + mass=[0.49865015, 0.81859462], + volume=15, + ) + self.fractions = [fraction_1, fraction_2] + + component_system = ComponentSystem(['A', 'B']) + self.reference = FractionationReference( + 'fractions', [fraction_1, fraction_2], + component_system=component_system + ) + + self.solution = SolutionIO( + 'simple', comp_2, time, solution_2_gaussian, flow_rate=q_const + ) + + def test_metric(self): + # Compare with itself + difference = FractionationSSE( + self.reference, + components=['A'], + ) + metrics_expected = [1.30315857e-19] + metrics = difference.evaluate(self.solution) + np.testing.assert_almost_equal(metrics, metrics_expected) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_equilibrium.py b/tests/test_equilibrium.py index 33b0fa33..c1a9b6e8 100644 --- a/tests/test_equilibrium.py +++ b/tests/test_equilibrium.py @@ -212,7 +212,7 @@ def test_adsorption(self): buffer = [1, 1] eq = equilibria.simulate_solid_equilibria(self.sma, buffer) eq_expected = [10/3, 2*10/3] - np.testing.assert_almost_equal(eq, eq_expected) + np.testing.assert_almost_equal(eq, eq_expected, decimal=4) if __name__ == '__main__': diff --git a/tests/test_flow_sheet.py b/tests/test_flow_sheet.py index 44c32fc1..1e19563f 100644 --- a/tests/test_flow_sheet.py +++ b/tests/test_flow_sheet.py @@ -5,11 +5,46 @@ from CADETProcess import CADETProcessError from CADETProcess.processModel import ComponentSystem from CADETProcess.processModel import ( - Inlet, Cstr, LumpedRateModelWithoutPores, Outlet + Inlet, Cstr, MCT, LumpedRateModelWithoutPores, Outlet ) from CADETProcess.processModel import FlowSheet +def assert_almost_equal_dict( + dict_actual, dict_expected, decimal=7, verbose=True): + """Helper function to assert nested dicts are (almost) equal. + + Because of floating point calculations, it is necessary to use + `np.assert_almost_equal` to check the flow rates. However, this does not + work well with nested dicts which is why this helper function was written. + + Parameters + ---------- + dict_actual : dict + The object to check. + dict_expected : dict + The expected object. + decimal : int, optional + Desired precision, default is 7. + err_msg : str, optional + The error message to be printed in case of failure. + verbose : bool, optional + If True, the conflicting values are appended to the error message. + + """ + for key in dict_actual: + if isinstance(dict_actual[key], dict): + assert_almost_equal_dict(dict_actual[key], dict_expected[key]) + + else: + np.testing.assert_almost_equal( + dict_actual[key], dict_expected[key], + decimal=decimal, + err_msg=f'Dicts are not equal in key {key}.', + verbose=verbose + ) + + def setup_single_cstr_flow_sheet(component_system=None): """ Set up a simple `FlowSheet` with a single Continuous Stirred Tank Reactor (CSTR). @@ -172,24 +207,59 @@ def test_connections(self): outlet = self.ssr_flow_sheet['outlet'] expected_connections = { feed: { - 'origins': [], - 'destinations': [cstr], + 'origins': None, + 'destinations': { + None: { + cstr:[None], + }, + }, }, eluent: { - 'origins': [], - 'destinations': [column], + 'origins': None, + 'destinations': { + None: { + column:[None], + }, + }, }, + cstr: { - 'origins': [feed, column], - 'destinations': [column], + 'origins': { + None: { + feed:[None], + column:[None], + }, + }, + 'destinations': { + None: { + column:[None], + }, + }, }, + column: { - 'origins': [cstr, eluent], - 'destinations': [cstr, outlet], + 'origins': { + None: { + cstr:[None], + eluent:[None], + }, + }, + 'destinations': { + None: { + cstr:[None], + outlet:[None], + }, + }, }, + outlet: { - 'origins': [column], - 'destinations': [], + 'origins':{ + None: { + column:[None], + }, + }, + + 'destinations': None, }, } @@ -225,30 +295,154 @@ def test_name_decorator(self): flow_sheet.add_connection(eluent, 'column') flow_sheet.add_connection(column, cstr) flow_sheet.add_connection('column', outlet) - expected_connections = { feed: { - 'origins': [], - 'destinations': [cstr], + 'origins': None, + 'destinations': { + 0: { + cstr:[0], + }, + }, }, eluent: { - 'origins': [], - 'destinations': [column], + 'origins': None, + 'destinations': { + 0: { + column:[0], + }, + }, }, + cstr: { - 'origins': [feed, column], - 'destinations': [column], + 'origins': { + 0: { + feed:[0], + column:[0], + }, + }, + 'destinations': { + 0: { + column:[0], + }, + }, }, + column: { - 'origins': [cstr, eluent], - 'destinations': [cstr, outlet], + 'origins': { + 0: { + cstr:[0], + eluent:[0], + }, + }, + 'destinations': { + 0: { + cstr:[0], + outlet:[0], + }, + }, }, + outlet: { - 'origins': [column], - 'destinations': [], + 'origins':{ + 0: { + column:[0], + }, + }, + + 'destinations': None, }, } + self.assertDictEqual( + self.ssr_flow_sheet.connections, expected_connections + ) + + self.assertTrue(self.ssr_flow_sheet.connection_exists(feed, cstr)) + self.assertTrue(self.ssr_flow_sheet.connection_exists(eluent, column)) + self.assertTrue(self.ssr_flow_sheet.connection_exists(column, outlet)) + + self.assertFalse(self.ssr_flow_sheet.connection_exists(feed, eluent)) + + def test_name_decorator(self): + feed = Inlet(self.component_system, name='feed') + eluent = Inlet(self.component_system, name='eluent') + cstr = Cstr(self.component_system, name='cstr') + column = LumpedRateModelWithoutPores( + self.component_system, name='column' + ) + outlet = Outlet(self.component_system, name='outlet') + + flow_sheet = FlowSheet(self.component_system) + + flow_sheet.add_unit(feed) + flow_sheet.add_unit(eluent) + flow_sheet.add_unit(cstr) + flow_sheet.add_unit(column) + flow_sheet.add_unit(outlet) + + flow_sheet.add_connection('feed', 'cstr') + flow_sheet.add_connection(cstr, column) + flow_sheet.add_connection(eluent, 'column') + flow_sheet.add_connection(column, cstr) + flow_sheet.add_connection('column', outlet) + + expected_connections = { + feed: { + 'origins': None, + 'destinations': { + None: { + cstr:[None], + }, + }, + }, + eluent: { + 'origins': None, + 'destinations': { + None: { + column:[None], + }, + }, + }, + + cstr: { + 'origins': { + None: { + feed:[None], + column:[None], + }, + }, + 'destinations': { + None: { + column:[None], + }, + }, + }, + + column: { + 'origins': { + None: { + cstr:[None], + eluent:[None], + }, + }, + 'destinations': { + None: { + outlet:[None], + cstr:[None], + }, + }, + }, + + outlet: { + 'origins':{ + None: { + column:[None], + }, + }, + + 'destinations': None, + }, + } self.assertDictEqual(flow_sheet.connections, expected_connections) # Connection already exists @@ -261,7 +455,7 @@ def test_name_decorator(self): # Destination not found with self.assertRaises(CADETProcessError): - flow_sheet.add_connection('wrong_origin', cstr) + flow_sheet.add_connection(cstr, 'wrong_destination') def test_flow_rates(self): # Injection @@ -272,45 +466,94 @@ def test_flow_rates(self): expected_flow_rates = { 'feed': { - 'total_out': (0, 0, 0, 0), + 'total_out':{ + None: (0, 0, 0, 0), + }, + 'destinations': { - 'cstr': (0, 0, 0, 0), + None: { + 'cstr': { + None: (0, 0, 0, 0), + }, + }, }, }, 'eluent': { - 'total_out': (0, 0, 0, 0), + 'total_out': { + None: (0, 0, 0, 0), + }, 'destinations': { - 'column': (0, 0, 0, 0), + None: { + 'column': { + None: (0, 0, 0, 0), + }, + }, }, }, 'cstr': { - 'total_in': (0.0, 0, 0, 0), - 'total_out': (1.0, 0, 0, 0), + 'total_in': { + None: (0, 0, 0, 0), + }, + 'total_out': { + None: (1.0, 0, 0, 0), + }, 'origins': { - 'feed': (0, 0, 0, 0), - 'column': (0, 0, 0, 0), + None: { + 'feed': { + None: (0, 0, 0, 0), + }, + 'column': { + None: (0, 0, 0, 0), + }, + }, }, 'destinations': { - 'column': (1.0, 0, 0, 0), + None: { + 'column': { + None: (1.0, 0, 0, 0), + }, + } }, }, 'column': { - 'total_in': (1.0, 0, 0, 0), - 'total_out': (1.0, 0, 0, 0), + 'total_in': { + None: (1.0, 0, 0, 0), + }, + 'total_out': { + None: (1.0, 0, 0, 0), + }, 'origins': { - 'cstr': (1.0, 0, 0, 0), - 'eluent': (0, 0, 0, 0), + None: { + 'cstr': { + None: (1.0, 0, 0, 0), + }, + 'eluent': { + None: (0, 0, 0, 0), + }, + }, }, 'destinations': { - 'cstr': (0, 0, 0, 0), - 'outlet': (1.0, 0, 0, 0), + None: { + 'cstr': { + None: (0, 0, 0, 0), + }, + 'outlet': { + None: (1.0, 0, 0, 0), + }, + }, }, }, 'outlet': { 'origins': { - 'column': (1.0, 0, 0, 0), + None: { + 'column': { + None: (1.0, 0, 0, 0), + }, + }, }, - 'total_in': (1.0, 0, 0, 0), + 'total_in': { + None: (1.0, 0, 0, 0), + }, }, } @@ -326,46 +569,94 @@ def test_flow_rates(self): expected_flow_rates = { 'feed': { - 'total_out': (1, 0, 0, 0), + 'total_out': { + None: (1, 0, 0, 0), + }, 'destinations': { - 'cstr': (1, 0, 0, 0), + None: { + 'cstr': { + None: (1, 0, 0, 0), + }, + } }, }, 'eluent': { - 'total_out': (1, 0, 0, 0), + 'total_out': { + None: (1, 0, 0, 0) + }, 'destinations': { - 'column': (1, 0, 0, 0), + None: { + 'column': { + None: (1, 0, 0, 0), + }, + }, }, }, 'cstr': { - 'total_in': (1.0, 0, 0, 0), - 'total_out': (0.0, 0, 0, 0), + 'total_in': { + None: (1.0, 0, 0, 0), + }, + 'total_out': { + None: (0.0, 0, 0, 0), + }, 'origins': { - 'feed': (1, 0, 0, 0), - 'column': (0, 0, 0, 0), + None: { + 'feed': { + None: (1, 0, 0, 0), + }, + 'column': { + None: (0, 0, 0, 0), + }, + }, }, 'destinations': { - 'column': (0.0, 0, 0, 0), + None: { + 'column': { + None: (0.0, 0, 0, 0), + }, + }, }, }, 'column': { - 'total_in': (1.0, 0, 0, 0), - 'total_out': (1.0, 0, 0, 0), + 'total_in': { + None: (1.0, 0, 0, 0), + }, + 'total_out': { + None: (1.0, 0, 0, 0), + }, 'origins': { - 'cstr': (0, 0, 0, 0), - 'eluent': (1, 0, 0, 0), + None: { + 'cstr': { + None: (0, 0, 0, 0), + }, + 'eluent': { + None: (1, 0, 0, 0), + }, + }, }, 'destinations': { - 'cstr': (0, 0, 0, 0), - 'outlet': (1.0, 0, 0, 0), + None: { + 'cstr': { + None: (0, 0, 0, 0), + }, + 'outlet': { + None: (1.0, 0, 0, 0), + }, + }, }, }, 'outlet': { 'origins': { - 'column': (1.0, 0, 0, 0), + None: { + 'column':{ + None: (1.0, 0, 0, 0), + }, + }, }, - 'total_in': (1.0, 0, 0, 0), + 'total_in': { + None: (1.0, 0, 0, 0), }, + }, } np.testing.assert_equal( self.ssr_flow_sheet.get_flow_rates(), expected_flow_rates @@ -379,45 +670,93 @@ def test_flow_rates(self): expected_flow_rates = { 'feed': { - 'total_out': (0, 0, 0, 0), + 'total_out': { + None: (0, 0, 0, 0), + }, 'destinations': { - 'cstr': (0, 0, 0, 0), + None: { + 'cstr': { + None: (0, 0, 0, 0), + }, + }, }, }, 'eluent': { - 'total_out': (1, 0, 0, 0), + 'total_out': { + None: (1, 0, 0, 0), + }, 'destinations': { - 'column': (1, 0, 0, 0), + None: { + 'column': { + None: (1, 0, 0, 0), + }, + }, }, }, 'cstr': { - 'total_in': (0.0, 0, 0, 0), - 'total_out': (0.0, 0, 0, 0), + 'total_in': { + None: (0, 0, 0, 0), + }, + 'total_out': { + None: (0, 0, 0, 0), + }, 'origins': { - 'feed': (0, 0, 0, 0), - 'column': (0, 0, 0, 0), + None: { + 'feed': { + None: (0, 0, 0, 0), + }, + 'column': { + None: (0, 0, 0, 0), + }, + }, }, 'destinations': { - 'column': (0.0, 0, 0, 0), + None: { + 'column': { + None: (0.0, 0, 0, 0), + }, + }, }, }, 'column': { - 'total_in': (1.0, 0, 0, 0), - 'total_out': (1.0, 0, 0, 0), + 'total_in': { + None: (1.0, 0, 0, 0), + }, + 'total_out': { + None: (1.0, 0, 0, 0), + }, 'origins': { - 'cstr': (0, 0, 0, 0), - 'eluent': (1, 0, 0, 0), + None: { + 'cstr': { + None: (0, 0, 0, 0), + }, + 'eluent': { + None: (1, 0, 0, 0), + }, + }, }, 'destinations': { - 'cstr': (0, 0, 0, 0), - 'outlet': (1.0, 0, 0, 0), + None: { + 'cstr': { + None: (0, 0, 0, 0), + }, + 'outlet': { + None: (1.0, 0, 0, 0), + }, + }, }, }, 'outlet': { 'origins': { - 'column': (1.0, 0, 0, 0), + None: { + 'column': { + None: (1.0, 0, 0, 0), + }, + }, }, - 'total_in': (1.0, 0, 0, 0), + 'total_in': { + None: (1.0, 0, 0, 0), + }, }, } @@ -433,46 +772,94 @@ def test_flow_rates(self): expected_flow_rates = { 'feed': { - 'total_out': (0, 0, 0, 0), + 'total_out': { + None: (0, 0, 0, 0), + }, 'destinations': { - 'cstr': (0, 0, 0, 0), + None: { + 'cstr': { + None: (0, 0, 0, 0), + }, + }, }, }, 'eluent': { - 'total_out': (1, 0, 0, 0), + 'total_out': { + None: (1, 0, 0, 0), + }, 'destinations': { - 'column': (1, 0, 0, 0), + None: { + 'column': { + None: (1, 0, 0, 0), + }, + }, }, }, 'cstr': { - 'total_in': (1.0, 0, 0, 0), - 'total_out': (0.0, 0, 0, 0), + 'total_in': { + None: (1.0, 0, 0, 0), + }, + 'total_out': { + None: (0.0, 0, 0, 0), + }, 'origins': { - 'feed': (0, 0, 0, 0), - 'column': (1, 0, 0, 0), + None: { + 'feed': { + None: (0, 0, 0, 0), + }, + 'column': { + None: (1, 0, 0, 0), + }, + }, }, 'destinations': { - 'column': (0.0, 0, 0, 0), + None: { + 'column': { + None: (0.0, 0, 0, 0), + }, + }, }, }, 'column': { - 'total_in': (1.0, 0, 0, 0), - 'total_out': (1.0, 0, 0, 0), + 'total_in': { + None: (1.0, 0, 0, 0), + }, + 'total_out': { + None: (1.0, 0, 0, 0), + }, 'origins': { - 'cstr': (0, 0, 0, 0), - 'eluent': (1, 0, 0, 0), + None: { + 'cstr': { + None: (0, 0, 0, 0), + }, + 'eluent': { + None: (1, 0, 0, 0), + }, + }, }, 'destinations': { - 'cstr': (1, 0, 0, 0), - 'outlet': (0.0, 0, 0, 0), + None: { + 'cstr': { + None: (1, 0, 0, 0), + }, + 'outlet': { + None: (0.0, 0, 0, 0), + }, + }, }, }, 'outlet': { 'origins': { - 'column': (0, 0, 0, 0), + None: { + 'column': { + None: (0, 0, 0, 0), + }, + }, }, - 'total_in': (0, 0, 0, 0), + 'total_in': { + None: (0.0, 0, 0, 0), }, + }, } np.testing.assert_equal( @@ -482,8 +869,12 @@ def test_flow_rates(self): # Single Cstr expected_flow_rates = { 'cstr': { - 'total_in': [0.0, 0.0, 0.0, 0.0], - 'total_out': [0.0, 0.0, 0.0, 0.0], + 'total_in': { + None:[0.0, 0.0, 0.0, 0.0], + }, + 'total_out': { + None:[0.0, 0.0, 0.0, 0.0] + }, 'origins': {}, 'destinations': {} } @@ -507,34 +898,40 @@ def test_check_connectivity(self): def test_output_state(self): column = self.ssr_flow_sheet.column + output_state_expected = {None: [1, 0]} + output_state = self.ssr_flow_sheet._output_states[column] + np.testing.assert_equal(output_state, output_state_expected) + output_state_expected = [1, 0] output_state = self.ssr_flow_sheet.output_states[column] np.testing.assert_equal(output_state, output_state_expected) self.ssr_flow_sheet.set_output_state(column, [0, 1]) - output_state_expected = [0, 1] - output_state = self.ssr_flow_sheet.output_states[column] + output_state_expected = {None: [0, 1]} + output_state = self.ssr_flow_sheet._output_states[column] np.testing.assert_equal(output_state, output_state_expected) self.ssr_flow_sheet.set_output_state(column, 0) - output_state_expected = [1, 0] - output_state = self.ssr_flow_sheet.output_states[column] + output_state_expected = {None: [1, 0]} + output_state = self.ssr_flow_sheet._output_states[column] np.testing.assert_equal(output_state, output_state_expected) self.ssr_flow_sheet.set_output_state(column, [0.5, 0.5]) - output_state_expected = [0.5, 0.5] - output_state = self.ssr_flow_sheet.output_states[column] + output_state_expected = {None: [0.5, 0.5]} + output_state = self.ssr_flow_sheet._output_states[column] np.testing.assert_equal(output_state, output_state_expected) self.ssr_flow_sheet.set_output_state( column, { 'cstr': 0.1, - 'outlet': 0.9, + 'outlet': { + None: 0.9, + } } ) - output_state_expected = [0.1, 0.9] - output_state = self.ssr_flow_sheet.output_states[column] + output_state_expected = {None: [0.1, 0.9]} + output_state = self.ssr_flow_sheet._output_states[column] np.testing.assert_equal(output_state, output_state_expected) with self.assertRaises(TypeError): @@ -548,10 +945,44 @@ def test_output_state(self): column, { 'column': 0.1, - 'outlet': 0.9, + 'outlet': { + 0: 0.9, + } } ) + def test_add_connection_error(self): + """ + Test for all raised exceptions of add_connections. + """ + inlet = self.ssr_flow_sheet['eluent'] + column = self.ssr_flow_sheet['column'] + outlet = self.ssr_flow_sheet['outlet'] + external_unit = Cstr(self.component_system, name='external_unit') + + # Inlet can't be a destination + with self.assertRaises(CADETProcessError): + self.ssr_flow_sheet.add_connection(column, inlet) + + # Outlet can't be an origin + with self.assertRaises(CADETProcessError): + self.ssr_flow_sheet.add_connection(outlet, column) + + # Destination not part of flow_sheet + with self.assertRaises(CADETProcessError): + self.ssr_flow_sheet.add_connection(inlet, external_unit) + + # Origin not part of flow_sheet + with self.assertRaises(CADETProcessError): + self.ssr_flow_sheet.add_connection(external_unit, outlet) + + # Connection already exists + with self.assertRaises(CADETProcessError): + self.ssr_flow_sheet.add_connection(inlet, column) + + with self.assertRaisesRegex(CADETProcessError, "not a port of"): + self.ssr_flow_sheet.set_output_state(column, [0.5,0.5], 'channel_5') + class TestCstrFlowRate(unittest.TestCase): """ @@ -590,11 +1021,11 @@ def test_continuous_flow(self): flow_rates = self.flow_sheet.get_flow_rates() - cstr_in = flow_rates['cstr']['total_in'] + cstr_in = flow_rates['cstr']['total_in'][None] cstr_in_expected = [1., 0., 0., 0.] np.testing.assert_almost_equal(cstr_in, cstr_in_expected) - cstr_out = flow_rates['cstr']['total_out'] + cstr_out = flow_rates['cstr']['total_out'][None] cstr_out_expected = [1., 0., 0., 0.] np.testing.assert_almost_equal(cstr_out, cstr_out_expected) @@ -603,11 +1034,11 @@ def test_continuous_flow(self): flow_rates = self.flow_sheet.get_flow_rates() - cstr_in = flow_rates['cstr']['total_in'] + cstr_in = flow_rates['cstr']['total_in'][None] cstr_in_expected = [1., 1., 0., 0.] np.testing.assert_almost_equal(cstr_in, cstr_in_expected) - cstr_out = flow_rates['cstr']['total_out'] + cstr_out = flow_rates['cstr']['total_out'][None] cstr_out_expected = [1., 1., 0., 0.] np.testing.assert_almost_equal(cstr_out, cstr_out_expected) @@ -616,11 +1047,11 @@ def test_no_flow(self): flow_rates = self.flow_sheet.get_flow_rates() - cstr_in = flow_rates['cstr']['total_in'] + cstr_in = flow_rates['cstr']['total_in'][None] cstr_in_expected = [0., 0., 0., 0.] np.testing.assert_almost_equal(cstr_in, cstr_in_expected) - cstr_out = flow_rates['cstr']['total_out'] + cstr_out = flow_rates['cstr']['total_out'][None] cstr_out_expected = [0., 0., 0., 0.] np.testing.assert_almost_equal(cstr_out, cstr_out_expected) @@ -629,11 +1060,11 @@ def test_no_flow(self): flow_rates = self.flow_sheet.get_flow_rates() - cstr_in = flow_rates['cstr']['total_in'] + cstr_in = flow_rates['cstr']['total_in'][None] cstr_in_expected = [0., 0., 0., 0.] np.testing.assert_almost_equal(cstr_in, cstr_in_expected) - cstr_out = flow_rates['cstr']['total_out'] + cstr_out = flow_rates['cstr']['total_out'][None] cstr_out_expected = [0., 0., 0., 0.] np.testing.assert_almost_equal(cstr_out, cstr_out_expected) @@ -643,11 +1074,11 @@ def test_holdup(self): flow_rates = self.flow_sheet.get_flow_rates() - cstr_in = flow_rates['cstr']['total_in'] + cstr_in = flow_rates['cstr']['total_in'][None] cstr_in_expected = [1., 0., 0., 0.] np.testing.assert_almost_equal(cstr_in, cstr_in_expected) - cstr_out = flow_rates['cstr']['total_out'] + cstr_out = flow_rates['cstr']['total_out'][None] cstr_out_expected = [0., 0., 0., 0.] np.testing.assert_almost_equal(cstr_out, cstr_out_expected) @@ -660,14 +1091,472 @@ def test_state_update(self): flow_rates = self.flow_sheet.get_flow_rates(state) - cstr_in = flow_rates['cstr']['total_in'] + cstr_in = flow_rates['cstr']['total_in'][None] cstr_in_expected = [1., 1., 0., 0.] np.testing.assert_almost_equal(cstr_in, cstr_in_expected) - cstr_out = flow_rates['cstr']['total_out'] + cstr_out = flow_rates['cstr']['total_out'][None] cstr_out_expected = [2., 2., 0., 0.] np.testing.assert_almost_equal(cstr_out, cstr_out_expected) +class TestPorts(unittest.TestCase): + def __init__(self, methodName='runTest'): + super().__init__(methodName) + + def setUp(self): + self.setup_mct_flow_sheet() + + self.setup_ccc_flow_sheet() + + def setup_mct_flow_sheet(self): + self.component_system = ComponentSystem(1) + + mct_flow_sheet = FlowSheet(self.component_system) + + inlet = Inlet(self.component_system, name='inlet') + mct_3c = MCT(self.component_system,nchannel=3, name='mct_3c') + mct_2c1 = MCT(self.component_system,nchannel=2, name='mct_2c1') + mct_2c2 = MCT(self.component_system,nchannel=2, name='mct_2c2') + outlet1 = Outlet(self.component_system, name='outlet1') + outlet2 = Outlet(self.component_system, name='outlet2') + + mct_flow_sheet.add_unit(inlet) + mct_flow_sheet.add_unit(mct_3c) + mct_flow_sheet.add_unit(mct_2c1) + mct_flow_sheet.add_unit(mct_2c2) + mct_flow_sheet.add_unit(outlet1) + mct_flow_sheet.add_unit(outlet2) + + mct_flow_sheet.add_connection(inlet, mct_3c, destination_port='channel_0') + mct_flow_sheet.add_connection(mct_3c, mct_2c1, origin_port='channel_0', destination_port='channel_0') + mct_flow_sheet.add_connection(mct_3c, mct_2c1, origin_port='channel_0', destination_port='channel_1') + mct_flow_sheet.add_connection(mct_3c, mct_2c2, origin_port='channel_1', destination_port='channel_0') + mct_flow_sheet.add_connection(mct_2c1, outlet1, origin_port='channel_0') + mct_flow_sheet.add_connection(mct_2c1, outlet1, origin_port='channel_1') + mct_flow_sheet.add_connection(mct_2c2, outlet2, origin_port='channel_0') + + + self.mct_flow_sheet = mct_flow_sheet + + def setup_ccc_flow_sheet(self): + self.component_system = ComponentSystem(1) + + ccc_flow_sheet = FlowSheet(self.component_system) + + inlet = Inlet(self.component_system, name='inlet') + ccc1 = MCT(self.component_system,nchannel=2, name='ccc1') + ccc2 = MCT(self.component_system,nchannel=2, name='ccc2') + ccc3 = MCT(self.component_system,nchannel=2, name='ccc3') + outlet = Outlet(self.component_system, name='outlet') + + ccc_flow_sheet.add_unit(inlet) + ccc_flow_sheet.add_unit(ccc1) + ccc_flow_sheet.add_unit(ccc2) + ccc_flow_sheet.add_unit(ccc3) + ccc_flow_sheet.add_unit(outlet) + + ccc_flow_sheet.add_connection(inlet, ccc1, destination_port='channel_0') + ccc_flow_sheet.add_connection(ccc1, ccc2, origin_port='channel_0', destination_port='channel_0') + ccc_flow_sheet.add_connection(ccc2, ccc3, origin_port='channel_0', destination_port='channel_0') + ccc_flow_sheet.add_connection(ccc3, outlet, origin_port='channel_0') + + + self.ccc_flow_sheet = ccc_flow_sheet + + def test_mct_connections(self): + inlet = self.mct_flow_sheet['inlet'] + mct_3c = self.mct_flow_sheet['mct_3c'] + mct_2c1 = self.mct_flow_sheet['mct_2c1'] + mct_2c2 = self.mct_flow_sheet['mct_2c2'] + outlet1 = self.mct_flow_sheet['outlet1'] + outlet2 = self.mct_flow_sheet['outlet2'] + + expected_connections = { + inlet: { + 'origins': None, + 'destinations': { + None: { + mct_3c: ['channel_0'], + }, + }, + }, + mct_3c: { + 'origins': { + 'channel_0': { + inlet: [None], + }, + 'channel_1': { + + }, + 'channel_2': { + + }, + }, + 'destinations': { + 'channel_0': { + mct_2c1: ['channel_0', 'channel_1'], + }, + 'channel_1': { + mct_2c2: ['channel_0'], + }, + 'channel_2': { + + }, + }, + }, + + mct_2c1: { + 'origins': { + 'channel_0': { + mct_3c:['channel_0'], + }, + + 'channel_1': { + mct_3c:['channel_0'], + }, + }, + 'destinations': { + 'channel_0': { + outlet1: [None], + }, + 'channel_1': { + outlet1: [None], + }, + }, + }, + + mct_2c2: { + 'origins': { + 'channel_0': { + mct_3c:['channel_1'], + }, + 'channel_1': { + + }, + }, + + 'destinations': { + 'channel_0': { + outlet2: [None], + }, + 'channel_1': { + + }, + }, + }, + + outlet1: { + 'origins':{ + None: { + mct_2c1:['channel_0','channel_1'], + }, + }, + + 'destinations': None, + }, + + outlet2: { + 'origins':{ + None: { + mct_2c2:['channel_0'], + }, + }, + + 'destinations': None, + }, + } + + self.assertDictEqual( + self.mct_flow_sheet.connections, expected_connections + ) + + self.assertTrue(self.mct_flow_sheet.connection_exists(inlet, mct_3c, destination_port='channel_0')) + self.assertTrue(self.mct_flow_sheet.connection_exists(mct_3c, mct_2c1, 'channel_0', 'channel_0')) + self.assertTrue(self.mct_flow_sheet.connection_exists(mct_3c, mct_2c1, 'channel_0', 'channel_1')) + self.assertTrue(self.mct_flow_sheet.connection_exists(mct_3c, mct_2c2, 'channel_1', 'channel_0')) + self.assertTrue(self.mct_flow_sheet.connection_exists(mct_2c1, outlet1, 'channel_0')) + self.assertTrue(self.mct_flow_sheet.connection_exists(mct_2c1, outlet1, 'channel_1')) + self.assertTrue(self.mct_flow_sheet.connection_exists(mct_2c2, outlet2, 'channel_0')) + + self.assertFalse(self.mct_flow_sheet.connection_exists(mct_2c2, outlet2, 'channel_1')) + self.assertFalse(self.mct_flow_sheet.connection_exists(inlet, mct_2c1, destination_port='channel_0')) + + def test_ccc_connections(self): + inlet = self.ccc_flow_sheet['inlet'] + ccc1 = self.ccc_flow_sheet['ccc1'] + ccc2 = self.ccc_flow_sheet['ccc2'] + ccc3 = self.ccc_flow_sheet['ccc3'] + outlet = self.ccc_flow_sheet['outlet'] + + expected_connections = { + inlet: { + 'origins': None, + 'destinations': { + None: { + ccc1: ['channel_0'], + }, + }, + }, + ccc1: { + 'origins': { + 'channel_0': { + inlet: [None], + }, + 'channel_1': { + + } + }, + 'destinations': { + 'channel_0': { + ccc2: ['channel_0'], + }, + 'channel_1': { + + } + }, + }, + + ccc2: { + 'origins': { + 'channel_0': { + ccc1: ['channel_0'], + }, + 'channel_1': { + + } + }, + 'destinations': { + 'channel_0': { + ccc3: ['channel_0'], + }, + 'channel_1': { + + } + }, + }, + + ccc3: { + 'origins': { + 'channel_0': { + ccc2: ['channel_0'], + }, + 'channel_1': { + + } + }, + 'destinations': { + 'channel_0': { + outlet: [None], + }, + 'channel_1': { + + } + }, + }, + + outlet: { + 'origins':{ + None: { + ccc3:['channel_0'], + }, + }, + + 'destinations': None, + }, + } + + self.assertDictEqual( + self.ccc_flow_sheet.connections, expected_connections + ) + + self.assertTrue(self.ccc_flow_sheet.connection_exists(inlet, ccc1, destination_port='channel_0')) + self.assertTrue(self.ccc_flow_sheet.connection_exists(ccc1, ccc2, 'channel_0', 'channel_0')) + self.assertTrue(self.ccc_flow_sheet.connection_exists(ccc2, ccc3, 'channel_0', 'channel_0')) + self.assertTrue(self.ccc_flow_sheet.connection_exists(ccc3, outlet, 'channel_0')) + + self.assertFalse(self.ccc_flow_sheet.connection_exists(inlet, outlet)) + + def test_mct_flow_rate_calculation(self): + + mct_flow_sheet = self.mct_flow_sheet + + mct_flow_sheet.inlet.flow_rate = 1 + + inlet = self.mct_flow_sheet['inlet'] + mct_3c = self.mct_flow_sheet['mct_3c'] + mct_2c1 = self.mct_flow_sheet['mct_2c1'] + mct_2c2 = self.mct_flow_sheet['mct_2c2'] + +# mct_flow_sheet.set_output_state(mct_3c, [0.5, 0.5], 0) + + expected_flow = { + 'inlet': { + 'total_out': { + None: (1,0,0,0) + }, + 'destinations': { + None: { + 'mct_3c': { + 'channel_0': (1,0,0,0) + } + } + } + }, + 'mct_3c': { + 'total_in': { + 'channel_0': (1,0,0,0), + 'channel_1': (0,0,0,0), + 'channel_2': (0,0,0,0) + }, + 'origins': { + 'channel_0': { + 'inlet':{ + None: (1,0,0,0) + } + } + }, + 'total_out': { + 'channel_0': (1,0,0,0), + 'channel_1': (0,0,0,0), + 'channel_2': (0,0,0,0) + }, + 'destinations': { + 'channel_0': { + 'mct_2c1': { + 'channel_0': (1,0,0,0), + 'channel_1': (0,0,0,0) + } + }, + 'channel_1': { + 'mct_2c2': { + 'channel_0': (0,0,0,0) + } + } + } + }, + 'mct_2c1': { + 'total_in': { + 'channel_0': (1,0,0,0), + 'channel_1': (0,0,0,0) + }, + 'origins': { + 'channel_0': { + 'mct_3c': { + 'channel_0': (1,0,0,0) + } + }, + 'channel_1': { + 'mct_3c': { + 'channel_0': (0,0,0,0) + } + } + }, + 'total_out': { + 'channel_0': (1,0,0,0), + 'channel_1': (0,0,0,0) + }, + 'destinations': { + 'channel_0': { + 'outlet1': { + None: (1,0,0,0) + } + }, + 'channel_1': { + 'outlet1': { + None: (0,0,0,0) + } + } + } + }, + + 'mct_2c2': { + 'total_in': { + 'channel_0': (0,0,0,0), + 'channel_1': (0,0,0,0) + }, + 'origins': { + 'channel_0': { + 'mct_3c': { + 'channel_1': (0,0,0,0) + } + }, + }, + 'total_out': { + 'channel_0': (0,0,0,0), + 'channel_1': (0,0,0,0) + }, + 'destinations': { + 'channel_0': { + 'outlet2': { + None: (0,0,0,0) + } + } + } + }, + 'outlet1': { + 'total_in': { + None: (1,0,0,0) + }, + 'origins': { + None: { + 'mct_2c1': { + 'channel_0': (1,0,0,0), + 'channel_1': (0,0,0,0) + } + } + } + }, + 'outlet2': { + 'total_in': { + None: (0,0,0,0) + }, + 'origins': { + None: { + 'mct_2c2': { + 'channel_0': (0,0,0,0) + } + } + } + } + } + + + flow_rates = mct_flow_sheet.get_flow_rates() + + + assert_almost_equal_dict(flow_rates, expected_flow) + + + + def test_port_add_connection(self): + + inlet = self.mct_flow_sheet['inlet'] + mct_3c = self.mct_flow_sheet['mct_3c'] + mct_2c2 = self.mct_flow_sheet['mct_2c2'] + outlet1 = self.mct_flow_sheet['outlet1'] + + with self.assertRaises(CADETProcessError): + self.mct_flow_sheet.add_connection(inlet, mct_3c, origin_port=0, destination_port=5) + + with self.assertRaises(CADETProcessError): + self.mct_flow_sheet.add_connection(inlet, mct_3c, origin_port=5, destination_port=0) + + with self.assertRaises(CADETProcessError): + self.mct_flow_sheet.add_connection(mct_2c2, outlet1, origin_port=0, destination_port=5) + + def test_set_output_state(self): + + mct_3c = self.mct_flow_sheet['mct_3c'] + + with self.assertRaises(CADETProcessError): + self.mct_flow_sheet.set_output_state(mct_3c, [0.5,0.5]) + + with self.assertRaises(CADETProcessError): + self.mct_flow_sheet.set_output_state(mct_3c, [0.5,0.5], 'channel_5') + + with self.assertRaises(CADETProcessError): + self.mct_flow_sheet.set_output_state(mct_3c, {'mct_2c1': {'channel_0': 0.5 , 'channel_5': 0.5}}, 'channel_0') + class TestFlowRateMatrix(unittest.TestCase): """Test calculation of flow rates with another simple testcase by @daklauss""" @@ -711,84 +1600,99 @@ def test_matrix_example(self): expected_flow_rates = { 'inlet': { - 'total_out': (1, 0, 0, 0), + 'total_out': { + None: (1, 0, 0, 0), + }, 'destinations': { - 'cstr1': (0.3, 0, 0, 0), - 'cstr2': (0.7, 0, 0, 0), + None: { + 'cstr1': { + None: (0.3, 0, 0, 0), + }, + 'cstr2': { + None: (0.7, 0, 0, 0), + }, + }, }, }, 'cstr1': { - 'total_in': (0.65, 0, 0, 0), - 'total_out': (0.65, 0, 0, 0), + 'total_in': { + None: (0.65, 0, 0, 0), + }, + 'total_out': { + None: (0.65, 0, 0, 0), + }, 'origins': { - 'inlet': (0.3, 0, 0, 0), - 'cstr2': (0.35, 0, 0, 0), + None: { + 'inlet': { + None: (0.3, 0, 0, 0), + }, + 'cstr2': { + None: (0.35, 0, 0, 0), + }, + } }, 'destinations': { - 'outlet1': (0.65, 0, 0, 0), + None: { + 'outlet1': { + None: (0.65, 0, 0, 0), + }, + }, }, }, 'cstr2': { - 'total_in': (0.7, 0, 0, 0), - 'total_out': (0.7, 0, 0, 0), + 'total_in': { + None: (0.7, 0, 0, 0), + }, + 'total_out': { + None: (0.7, 0, 0, 0), + }, 'origins': { - 'inlet': (0.7, 0, 0, 0), + None: { + 'inlet': { + None: (0.7, 0, 0, 0), + }, + } }, 'destinations': { - 'cstr1': (0.35, 0, 0, 0), - 'outlet2': (0.35, 0, 0, 0), + None: { + 'cstr1': { + None: (0.35, 0, 0, 0), + }, + 'outlet2': { + None: (0.35, 0, 0, 0), + }, + }, }, }, 'outlet1': { 'origins': { - 'cstr1': (0.65, 0, 0, 0), + None: { + 'cstr1': { + None: (0.65, 0, 0, 0), + }, + }, + }, + 'total_in': { + None: (0.65, 0, 0, 0), }, - 'total_in': (0.65, 0, 0, 0), }, 'outlet2': { 'origins': { - 'cstr2': (0.35, 0, 0, 0), + None: { + 'cstr2': { + None: (0.35, 0, 0, 0), + }, + } }, - 'total_in': (0.35, 0, 0, 0), - } + 'total_in': { + None: (0.35, 0, 0, 0), + }, + } } calc_flow_rate = self.flow_sheet.get_flow_rates() - def assert_almost_equal_dict( - dict_actual, dict_expected, decimal=7, verbose=True): - """Helper function to assert nested dicts are (almost) equal. - - Because of floating point calculations, it is necessary to use - `np.assert_almost_equal` to check the flow rates. However, this does not - work well with nested dicts which is why this helper function was written. - - Parameters - ---------- - dict_actual : dict - The object to check. - dict_expected : dict - The expected object. - decimal : int, optional - Desired precision, default is 7. - err_msg : str, optional - The error message to be printed in case of failure. - verbose : bool, optional - If True, the conflicting values are appended to the error message. - - """ - for key in dict_actual: - if isinstance(dict_actual[key], dict): - assert_almost_equal_dict(dict_actual[key], dict_expected[key]) - else: - np.testing.assert_almost_equal( - dict_actual[key], dict_expected[key], - decimal=decimal, - err_msg=f'Dicts are not equal in key {key}.', - verbose=verbose - ) - assert_almost_equal_dict(calc_flow_rate, expected_flow_rates) @@ -824,29 +1728,57 @@ def test_matrix_self_example(self): expected_flow_rates = { 'inlet': { - 'total_out': (1, 0, 0, 0), + 'total_out': { + None: (1, 0, 0, 0), + }, 'destinations': { - 'cstr': (1, 0, 0, 0) + None: { + 'cstr': { + None: (1, 0, 0, 0), + }, + }, }, }, 'cstr': { - 'total_in': (2, 0, 0, 0), - 'total_out': (2, 0, 0, 0), + 'total_in': { + None: (2, 0, 0, 0), + }, + 'total_out': { + None: (2, 0, 0, 0), + }, 'origins': { - 'inlet': (1, 0, 0, 0), - 'cstr': (1, 0, 0, 0), + None: { + 'inlet': { + None: (1, 0, 0, 0), + }, + 'cstr': { + None: (1, 0, 0, 0), + }, + }, }, 'destinations': { - 'outlet': (1, 0, 0, 0), - 'cstr': (1, 0, 0, 0) + None: { + 'outlet': { + None: (1, 0, 0, 0), + }, + 'cstr': { + None: (1, 0, 0, 0), + }, + }, }, }, 'outlet': { - 'total_in': (1, 0, 0, 0), + 'total_in': { + None: (1, 0, 0, 0), + }, 'origins': { - 'cstr': (1, 0, 0, 0) - } - } + None: { + 'cstr': { + None: (1, 0, 0, 0), + }, + }, + }, + }, } calc_flow_rate = self.flow_sheet.get_flow_rates() np.testing.assert_equal(calc_flow_rate, expected_flow_rates) @@ -904,37 +1836,73 @@ def test_expelled_circuit_with_flow(self): # Solvable because both disconnected circles have their own flow rates expected_flow_rates = { 'inlet': { - 'total_out': (1, 0, 0, 0), + 'total_out': { + None: (1, 0, 0, 0), + }, 'destinations': { - 'outlet': (1, 0, 0, 0) + None: { + 'outlet': { + None: (1, 0, 0, 0), + }, + }, }, }, 'cstr1': { - 'total_in': (1, 0, 0, 0), - 'total_out': (1, 0, 0, 0), + 'total_in': { + None: (1, 0, 0, 0), + }, + 'total_out': { + None: (1, 0, 0, 0), + }, 'origins': { - 'cstr2': (1, 0, 0, 0), + None: { + 'cstr2': { + None: (1, 0, 0, 0), + }, + }, }, 'destinations': { - 'cstr2': (1, 0, 0, 0) + None: { + 'cstr2': { + None: (1, 0, 0, 0) + }, + }, }, }, 'cstr2': { - 'total_in': (1, 0, 0, 0), - 'total_out': (1, 0, 0, 0), + 'total_in': { + None: (1, 0, 0, 0), + }, + 'total_out': { + None: (1, 0, 0, 0), + }, 'origins': { - 'cstr1': (1, 0, 0, 0), + None: { + 'cstr1': { + None: (1, 0, 0, 0), + }, + }, }, 'destinations': { - 'cstr1': (1, 0, 0, 0) + None: { + 'cstr1': { + None: (1, 0, 0, 0), + }, + }, }, }, 'outlet': { - 'total_in': (1, 0, 0, 0), + 'total_in': { + None: (1, 0, 0, 0), + }, 'origins': { - 'inlet': (1, 0, 0, 0) - } - } + None: { + 'inlet': { + None: (1, 0, 0, 0), + }, + }, + }, + }, } flow_sheet = self.flow_sheet diff --git a/tests/test_individual.py b/tests/test_individual.py index 6a3440fc..c7696c0b 100644 --- a/tests/test_individual.py +++ b/tests/test_individual.py @@ -21,7 +21,7 @@ def setup_individual(n_vars=2, n_obj=1, n_nonlin=0, n_meta=0, rng=None): else: m = None - return Individual(x, f, g, m) + return Individual(x, f=f, g=g, m=m) class TestHashArray(unittest.TestCase): @@ -75,7 +75,7 @@ def setUp(self): f = [-1] g = [2] m = [1] - self.individual_constr_meta = Individual(x, f, g, cv=g, m=m) + self.individual_constr_meta = Individual(x, f, g, cv_nonlincon=g, m=m) def test_dimensions(self): dimensions_expected = (2, 1, 0, 0) @@ -145,7 +145,7 @@ def test_to_dict(self): # Missing: Test for labels. # self.assertEqual(data['objective_labels'], self.individual_1.objective_labels) - # self.assertEqual(data['contraint_labels'], self.individual_1.contraint_labels) + # self.assertEqual(data['nonlinear_constraint_labels'], self.individual_1.nonlinear_constraint_labels) # self.assertEqual(data['meta_score_labels'], elf.individual_1.meta_score_labels) def test_from_dict(self): @@ -170,7 +170,7 @@ def test_from_dict(self): test_individual.objective_labels, self.individual_1.objective_labels ) self.assertEqual( - test_individual.contraint_labels, self.individual_1.contraint_labels + test_individual.nonlinear_constraint_labels, self.individual_1.nonlinear_constraint_labels ) self.assertEqual( test_individual.meta_score_labels, self.individual_1.meta_score_labels diff --git a/tests/test_optimization_integration.py b/tests/test_optimization_integration.py index 4d0d8a0a..59750944 100644 --- a/tests/test_optimization_integration.py +++ b/tests/test_optimization_integration.py @@ -17,18 +17,32 @@ class TestBatchElutionOptimizationSingleObjective(unittest.TestCase): def setUp(self): + self.tearDown() + + if not ( + test_batch_elution_single_objective_single_core + or + test_batch_elution_single_objective_multi_core + ): + return + settings.working_directory = './test_batch' + settings.temp_dir = './test_batch/tmp' + from examples.batch_elution.optimization_single import ( optimization_problem, optimizer ) + optimization_problem.cache_directory = './test_batch/diskcache_batch_elution_single' self.optimization_problem = optimization_problem self.optimizer = optimizer def tearDown(self): shutil.rmtree('./test_batch', ignore_errors=True) + shutil.rmtree('./diskcache_batch_elution_single', ignore_errors=True) shutil.rmtree('./tmp', ignore_errors=True) - shutil.rmtree('./diskcache', ignore_errors=True) + + settings.working_directory = None def test_single_core(self): if not test_batch_elution_single_objective_single_core: @@ -56,7 +70,7 @@ def test_multi_core(self): self.optimizer.n_cores = -2 self.optimizer.pop_size = 16 - self.optimizer.n_max_gen = 4 + self.optimizer.n_max_gen = 2 print("start test_batch_elution_single_objective_multi_core") @@ -73,18 +87,28 @@ def test_multi_core(self): class TestBatchElutionOptimizationMultiObjective(unittest.TestCase): def setUp(self): + self.tearDown() + + if not test_batch_elution_multi_objective: + return + settings.working_directory = './test_batch' + settings.temp_dir = './test_batch/tmp' + from examples.batch_elution.optimization_multi import ( optimization_problem, optimizer ) + optimization_problem.cache_directory = './test_batch/diskcache_batch_elution_multi' self.optimization_problem = optimization_problem self.optimizer = optimizer def tearDown(self): shutil.rmtree('./test_batch', ignore_errors=True) + shutil.rmtree('./diskcache_batch_elution_multi', ignore_errors=True) shutil.rmtree('./tmp', ignore_errors=True) - shutil.rmtree('./diskcache', ignore_errors=True) + + settings.working_directory = None @unittest.skipIf(__name__ != "__main__", "Only run test if test is run as __main__") def test_optimization(self): @@ -93,7 +117,7 @@ def test_optimization(self): self.optimizer.n_cores = -2 self.optimizer.pop_size = 16 - self.optimizer.n_max_gen = 4 + self.optimizer.n_max_gen = 2 print("start test_batch_elution_multi_objective") @@ -113,32 +137,41 @@ def test_optimization(self): class TestFitColumnParameters(unittest.TestCase): def setUp(self): + self.tearDown() + + if not test_fit_column_parameters: + return + settings.working_directory = './test_fit_column_parameters' + settings.temp_dir = './test_fit_column_parameters/tmp' data_dir = Path(__file__).parent.parent / 'examples/characterize_chromatographic_system/experimental_data' - shutil.rmtree('./experimental_data/', ignore_errors=True) shutil.copytree(data_dir, './experimental_data') from examples.characterize_chromatographic_system.column_transport_parameters import ( optimization_problem, optimizer ) + optimization_problem.cache_directory = './test_fit_column_parameters/diskcache_bed_porosity_axial_dispersion' self.optimization_problem = optimization_problem self.optimizer = optimizer def tearDown(self): shutil.rmtree('./test_fit_column_parameters', ignore_errors=True) + shutil.rmtree('./diskcache_bed_porosity_axial_dispersion/', ignore_errors=True) shutil.rmtree('./tmp', ignore_errors=True) - shutil.rmtree('./diskcache', ignore_errors=True) + shutil.rmtree('./experimental_data/', ignore_errors=True) + settings.working_directory = None + def test_optimization(self): if not test_fit_column_parameters: self.skipTest("Skipping test_fit_column_parameters") self.optimizer.n_cores = -2 self.optimizer.pop_size = 16 - self.optimizer.n_max_gen = 4 + self.optimizer.n_max_gen = 2 print("start test_fit_column_parameters") @@ -153,7 +186,6 @@ def test_optimization(self): print(f"Equivalent CPU time: {(end - start)* self.optimizer.n_cores} s") - if __name__ == '__main__': # Run the tests unittest.main() diff --git a/tests/test_optimization_problem.py b/tests/test_optimization_problem.py index a72420ee..452d45d5 100644 --- a/tests/test_optimization_problem.py +++ b/tests/test_optimization_problem.py @@ -330,7 +330,7 @@ def test_transform(self): ) -from test_events import TestHandler +from tests.test_events import TestHandler class Test_OptimizationVariableEvents(unittest.TestCase): def __init__(self, methodName='runTest'): @@ -608,8 +608,8 @@ def dummy_meta_score(f): def setup_optimization_problem( - n_vars=2, n_obj=1, n_lincon=0, n_nonlincon=0, n_meta=0, - bounds=None, obj_fun=None, nonlincon_fun=None, lincons=None, + n_vars=2, n_obj=1, n_lincon=0, n_lineqcon=0, n_nonlincon=0, n_meta=0, + bounds=None, obj_fun=None, nonlincon_fun=None, lincons=None, lineqcons=None, use_diskcache=False, ): optimization_problem = OptimizationProblem('simple', use_diskcache=use_diskcache) @@ -634,6 +634,15 @@ def setup_optimization_problem( for opt_vars, lhs, b in lincons: optimization_problem.add_linear_constraint(opt_vars, lhs, b) + if n_lineqcon > 0: + if lineqcons is None: + lineqcons = [ + ([f'var_{i_var}', f'var_{i_var+1}'], [1, -1], 0) + for i_var in range(n_lineqcon) + ] + for opt_vars, lhs, beq in lineqcons: + optimization_problem.add_linear_equality_constraint(opt_vars, lhs, beq) + if obj_fun is None: obj_fun = setup_dummy_eval_fun(n_obj) @@ -652,6 +661,7 @@ def setup_optimization_problem( bounds=0.5 ) + if n_meta > 0: optimization_problem.add_meta_score(dummy_meta_score) @@ -670,6 +680,10 @@ def setUp(self): self.optimization_problem = optimization_problem + def tearDown(self): + shutil.rmtree('./results_simple', ignore_errors=True) + settings.working_directory = None + def test_variable_names(self): names_expected = ['var_0', 'var_1'] names = self.optimization_problem.variable_names @@ -712,19 +726,21 @@ def test_add_linear_constraints(self): self.optimization_problem.add_linear_constraint( ['var_0', 'var_1'], [3, 3], 1 ) + self.optimization_problem.add_linear_constraint(['var_0', 'var_1'], 4) A_expected = np.array([ [1., -1.], [1., 0.], [1., 1.], [2., 2.], - [3., 3.] + [3., 3.], + [4., 4.], ]) A = self.optimization_problem.A np.testing.assert_almost_equal(A, A_expected) - b_expected = np.array([0, 0, 0, 0, 1]) + b_expected = np.array([0, 0, 0, 0, 1, 0]) b = self.optimization_problem.b np.testing.assert_almost_equal(b, b_expected) @@ -736,6 +752,41 @@ def test_add_linear_constraints(self): with self.assertRaises(CADETProcessError): self.optimization_problem.add_linear_constraint('var_0', []) + def test_add_linear_equality_constraints(self): + self.optimization_problem.add_linear_equality_constraint('var_0') + self.optimization_problem.add_linear_equality_constraint(['var_0', 'var_1']) + + self.optimization_problem.add_linear_equality_constraint( + ['var_0', 'var_1'], [2, 2] + ) + self.optimization_problem.add_linear_equality_constraint( + ['var_0', 'var_1'], [3, 3], 1 + ) + self.optimization_problem.add_linear_equality_constraint(['var_0', 'var_1'], 4) + + Aeq_expected = np.array([ + [1., 0.], + [1., 1.], + [2., 2.], + [3., 3.], + [4., 4.], + ]) + + Aeq = self.optimization_problem.Aeq + np.testing.assert_almost_equal(Aeq, Aeq_expected) + + beq_expected = np.array([0, 0, 0, 1, 0]) + beq = self.optimization_problem.beq + np.testing.assert_almost_equal(beq, beq_expected) + + # Variable does not exist + with self.assertRaises(CADETProcessError): + self.optimization_problem.add_linear_equality_constraint('inexistent') + + # Incorrect shape + with self.assertRaises(CADETProcessError): + self.optimization_problem.add_linear_equality_constraint('var_0', []) + def test_initial_values(self): x0_chebyshev_expected = [1/3, 2/3] x0_chebyshev = self.optimization_problem.get_chebyshev_center( @@ -1095,7 +1146,7 @@ def check_equality_constraints(X, problem, transformed_space=False): evaluate_constraints = lambda x: Aeq.dot(x) - beq lhs = np.array(list(map(evaluate_constraints, X))) - rhs = problem.eps_eq + rhs = problem.eps_lineq CV = np.all(np.abs(lhs) <= rhs, axis=1) return CV @@ -1142,14 +1193,20 @@ def check_constraint_transform(problem, check_constraint_func): assert np.all(~CV_test_invalid) def test_linear_inequality_constrained_transform(self): - problem = LinearConstraintsSooTestProblem2(transform="linear") + problem = LinearConstraintsSooTestProblem2( + transform="linear", + use_diskcache=False, + ) self.check_constraint_transform( problem, self.check_inequality_constraints ) def test_linear_equality_constrained_transform(self): - problem = LinearEqualityConstraintsSooTestProblem(transform="linear") + problem = LinearEqualityConstraintsSooTestProblem( + transform="linear", + use_diskcache=False, + ) self.check_constraint_transform( problem, self.check_equality_constraints @@ -1304,6 +1361,10 @@ def multi_obj(eval_obj): def test_evaluation(self): f_expected = [0, 0, 1, 2, 3, 2, 3] f = self.optimization_problem.evaluate_objectives([1, 1, 1]) + np.testing.assert_allclose(f, f_expected) + + f_expected = [[0, 0, 1, 2, 3, 2, 3], [0, 0, 1, 2, 3, 2, 3]] + f = self.optimization_problem.evaluate_objectives([[1, 1, 1], [1, 1, 1]]) np.testing.assert_allclose(f, f_expected) diff --git a/tests/test_optimization_results.py b/tests/test_optimization_results.py index 9f27a19f..48ba9418 100644 --- a/tests/test_optimization_results.py +++ b/tests/test_optimization_results.py @@ -5,8 +5,8 @@ from CADETProcess.optimization import OptimizationResults from CADETProcess.optimization import U_NSGA3 -from test_population import setup_population -from test_optimization_problem import setup_optimization_problem +from tests.test_population import setup_population +from tests.test_optimization_problem import setup_optimization_problem def setup_optimization_results( diff --git a/tests/test_optimizer_behavior.py b/tests/test_optimizer_behavior.py index 3129f822..acd54301 100644 --- a/tests/test_optimizer_behavior.py +++ b/tests/test_optimizer_behavior.py @@ -5,10 +5,13 @@ from CADETProcess.optimization import ( OptimizerBase, TrustConstr, + COBYLA, + NelderMead, SLSQP, U_NSGA3, GPEI, - NEHVI + NEHVI, + qNParEGO, ) @@ -22,12 +25,10 @@ NonlinearLinearConstraintsSooTestProblem, LinearConstraintsMooTestProblem, LinearNonlinearConstraintsMooTestProblem, - NonlinearConstraintsMooTestProblem + NonlinearConstraintsMooTestProblem, ) -# ========================= -# Test-Optimizer Setup -# ========================= +# %% Optimizer Setup SOO_TEST_KWARGS = { "atol": 0.05, # allows absolute 0.05 deviation (low values) of solution or @@ -37,26 +38,31 @@ MOO_TEST_KWARGS = { "atol": 0.01, "rtol": 0.1, - "mismatch_tol": 0.33, # 75 % of all solutions must lie on the pareto front + "mismatch_tol": 0.3333333333, # 75 % of all solutions must lie on the pareto front } -FTOL = 0.001 -XTOL = 0.001 -GTOL = 0.0001 +F_TOL = 0.001 +X_TOL = 0.001 + +CV_BOUNDS_TOL = 1e-9 +CV_LINCON_TOL = 1e-6 +CV_LINEQCON_TOL = 1e-9 +CV_NONLINCON_TOL = 0.0001 EXCLUDE_COMBINATIONS = [ - (GPEI, Rosenbrock, - "cannot solve problem with enough accuracy fast enough-"), - (U_NSGA3, LinearEqualityConstraintsSooTestProblem, - "HopsyProblem: operands could not be broadcast together with shapes (2,) (3,)"), + ( + U_NSGA3, + LinearEqualityConstraintsSooTestProblem, + "See also: https://jugit.fz-juelich.de/IBG-1/ModSim/hopsy/-/issues/152", + ), + (GPEI, Rosenbrock, "cannot solve problem with enough accuracy fast enough."), ] # this helps to test optimizers for hard problems NON_DEFAULT_PARAMETERS = [ - (NEHVI, LinearConstraintsMooTestProblem, - {"n_init_evals": 20, "n_max_evals": 40}), - (U_NSGA3, NonlinearConstraintsMooTestProblem, - {"pop_size": 300, "n_max_gen": 50}), + (NEHVI, LinearConstraintsMooTestProblem, {"n_init_evals": 20, "n_max_evals": 40}), + (U_NSGA3, NonlinearConstraintsMooTestProblem, {"pop_size": 300, "n_max_gen": 40}), + (U_NSGA3, Rosenbrock, {"pop_size": 300, "n_max_gen": 20}), ] @@ -75,24 +81,35 @@ def set_non_default_parameters(optimizer, problem): class TrustConstr(TrustConstr): - ftol = FTOL - xtol = XTOL - gtol = GTOL + x_tol = X_TOL + cv_nonlincon_tol = CV_NONLINCON_TOL + + +class COBYLA(COBYLA): + x_tol = X_TOL + cv_nonlincon_tol = CV_NONLINCON_TOL + + +class NelderMead(NelderMead): + x_tol = X_TOL + f_tol = F_TOL class SLSQP(SLSQP): - ftol = FTOL + x_tol = X_TOL + cv_lincon_tol = CV_LINCON_TOL class U_NSGA3(U_NSGA3): - ftol = FTOL - xtol = XTOL - cvtol = GTOL + f_tol = F_TOL + x_tol = X_TOL + cv_nonlincon_tol = CV_NONLINCON_TOL pop_size = 100 n_max_gen = 20 # before used 100 generations --> this did not improve the fit class GPEI(GPEI): + cv_lincon_tol = CV_LINCON_TOL n_init_evals = 40 early_stopping_improvement_bar = 1e-4 early_stopping_improvement_window = 10 @@ -100,53 +117,67 @@ class GPEI(GPEI): class NEHVI(NEHVI): + cv_lincon_tol = CV_LINCON_TOL n_init_evals = 50 early_stopping_improvement_bar = 1e-4 early_stopping_improvement_window = 10 n_max_evals = 60 -# ========================= -# Test problem factory -# ========================= - -@pytest.fixture(params=[ - # single objective problems - Rosenbrock, - LinearConstraintsSooTestProblem, - LinearConstraintsSooTestProblem2, - NonlinearConstraintsSooTestProblem, - LinearEqualityConstraintsSooTestProblem, - NonlinearLinearConstraintsSooTestProblem, - - # multi objective problems - LinearConstraintsMooTestProblem, - NonlinearConstraintsMooTestProblem, - LinearNonlinearConstraintsMooTestProblem, - - # transformed problems - partial(LinearConstraintsSooTestProblem, transform="linear"), - partial(LinearEqualityConstraintsSooTestProblem, transform="linear"), - partial(NonlinearLinearConstraintsSooTestProblem, transform="linear"), -]) +class qNParEGO(qNParEGO): + cv_lincon_tol = CV_LINCON_TOL + n_init_evals = 50 + early_stopping_improvement_bar = 1e-4 + early_stopping_improvement_window = 10 + n_max_evals = 70 + + +# %% Test problem factory + +@pytest.fixture( + params=[ + # single objective problems + Rosenbrock, + LinearConstraintsSooTestProblem, + LinearConstraintsSooTestProblem2, + NonlinearConstraintsSooTestProblem, + LinearEqualityConstraintsSooTestProblem, + NonlinearLinearConstraintsSooTestProblem, + + # multi objective problems + LinearConstraintsMooTestProblem, + NonlinearConstraintsMooTestProblem, + LinearNonlinearConstraintsMooTestProblem, + + # transformed problems + partial(LinearConstraintsSooTestProblem, transform="linear"), + partial(LinearEqualityConstraintsSooTestProblem, transform="linear"), + partial(NonlinearLinearConstraintsSooTestProblem, transform="linear"), + ] +) def optimization_problem(request): - return request.param() - - -@pytest.fixture(params=[ - SLSQP, - TrustConstr, - U_NSGA3, - GPEI, - NEHVI, -]) + return request.param(use_diskcache=False) + + +@pytest.fixture( + params=[ + TrustConstr, + COBYLA, + SLSQP, + NelderMead, + U_NSGA3, + GPEI, + NEHVI, + qNParEGO, + ] +) def optimizer(request): - return request.param() + optimizer = request.param() + optimizer.progress_freqency = None + return optimizer -# ========================= -# Tests -# ========================= +# %% Tests def test_convergence(optimization_problem: TestProblem, optimizer: OptimizerBase): # only test problems that the optimizer can handle. The rest of the tests @@ -164,7 +195,9 @@ def test_convergence(optimization_problem: TestProblem, optimizer: OptimizerBase optimization_problem.test_if_solved(results, MOO_TEST_KWARGS) -def test_from_initial_values(optimization_problem: TestProblem, optimizer: OptimizerBase): +def test_from_initial_values( + optimization_problem: TestProblem, optimizer: OptimizerBase +): if optimizer.check_optimization_problem(optimization_problem): skip_if_combination_excluded(optimizer, optimization_problem) @@ -202,8 +235,8 @@ def __call__(self, results): def test_resume_from_checkpoint( - optimization_problem: TestProblem, optimizer: OptimizerBase - ): + optimization_problem: TestProblem, optimizer: OptimizerBase +): pytest.skip() # TODO: Do we need to run this for all problems? diff --git a/tests/test_parallelization_adapter.py b/tests/test_parallelization_adapter.py index e7c4d973..788057b1 100644 --- a/tests/test_parallelization_adapter.py +++ b/tests/test_parallelization_adapter.py @@ -10,8 +10,8 @@ from CADETProcess.simulator import Cadet from CADETProcess.optimization import SequentialBackend -from test_cadet_adapter import detect_cadet -from test_optimization_problem import setup_optimization_problem +from tests.test_cadet_adapter import detect_cadet +from tests.test_optimization_problem import setup_optimization_problem parallel_backends_module = importlib.import_module( @@ -33,7 +33,7 @@ backends = [SequentialBackend] + parallel_backends -found_cadet, cli_path, install_path = detect_cadet() +found_cadet, install_path = detect_cadet() n_cores = 2 cpu_count = multiprocessing.cpu_count() @@ -94,6 +94,7 @@ def __init__(self, methodName='runTest'): def tearDown(self): shutil.rmtree('./tmp', ignore_errors=True) + shutil.rmtree('./test_parallelization', ignore_errors=True) def test_parallelization_backend(self): def evaluation_function(sleep_time=0.0): @@ -143,9 +144,11 @@ def __init__(self, methodName='runTest'): super().__init__(methodName) def tearDown(self): - shutil.rmtree('./test_parallelization', ignore_errors=True) settings.working_directory = None + shutil.rmtree('./test_parallelization', ignore_errors=True) + shutil.rmtree('./diskcache_simple', ignore_errors=True) + def test_parallel_optimization(self): def run_optimization(backend=None): def dummy_objective_function(x): diff --git a/tests/test_population.py b/tests/test_population.py index 3e0afc57..45fa7e66 100644 --- a/tests/test_population.py +++ b/tests/test_population.py @@ -4,7 +4,7 @@ from CADETProcess import CADETProcessError from CADETProcess.optimization import Individual, Population, ParetoFront -from test_individual import setup_individual +from tests.test_individual import setup_individual enable_plot = False @@ -28,39 +28,39 @@ def __init__(self, methodName='runTest'): def setUp(self): x = [1, 2] f = [-1] - self.individual_1 = Individual(x, f) + self.individual_1 = Individual(x, f=f) x = [2, 3] f = [-2] - self.individual_2 = Individual(x, f) + self.individual_2 = Individual(x, f=f) x = [1.001, 2] f = [-1.001] - self.individual_similar = Individual(x, f) + self.individual_similar = Individual(x, f=f) x = [1, 2] f = [-1, -2] - self.individual_multi_1 = Individual(x, f) + self.individual_multi_1 = Individual(x, f=f) x = [1.001, 2] f = [-1.001, -2] - self.individual_multi_2 = Individual(x, f) + self.individual_multi_2 = Individual(x, f=f) x = [1, 2] f = [-1] g = [3] - self.individual_constr_1 = Individual(x, f, g) + self.individual_constr_1 = Individual(x, f=f, g=g) x = [2, 3] f = [-2] g = [0] - self.individual_constr_2 = Individual(x, f, g) + self.individual_constr_2 = Individual(x, f=f, g=g) x = [2, 3] f = [-2, -2] g = [0] m = [-4] - self.individual_multi_meta_1 = Individual(x, f, m=m) + self.individual_multi_meta_1 = Individual(x, f=f, m=m) self.population = Population() self.population.add_individual(self.individual_1) @@ -138,7 +138,7 @@ def test_add_remove(self): self.individual_1, ignore_duplicate=False ) - new_individual = Individual([9, 10], [3], [-1]) + new_individual = Individual([9, 10], f=[3], g=[-1]) with self.assertRaises(CADETProcessError): self.population.add_individual(new_individual) diff --git a/tests/test_pymoo.py b/tests/test_pymoo.py index be0a5210..5c2f9fbd 100644 --- a/tests/test_pymoo.py +++ b/tests/test_pymoo.py @@ -1,14 +1,20 @@ import unittest +import shutil +from CADETProcess import settings from CADETProcess.optimization import U_NSGA3 -from test_optimization_problem import setup_optimization_problem +from tests.test_optimization_problem import setup_optimization_problem class Test_OptimizationProblemSimple(unittest.TestCase): def __init__(self, methodName='runTest'): super().__init__(methodName) + def tearDown(self): + shutil.rmtree('./results_simple', ignore_errors=True) + settings.working_directory = None + def test_restart_from_checkpoint(self): class Callback(): def __init__(self, n_calls=0, kill=True): diff --git a/tests/test_unit_operation.py b/tests/test_unit_operation.py index a40ecca4..936135b6 100644 --- a/tests/test_unit_operation.py +++ b/tests/test_unit_operation.py @@ -8,10 +8,11 @@ import numpy as np +from CADETProcess import CADETProcessError from CADETProcess.processModel import ComponentSystem from CADETProcess.processModel import ( Inlet, Cstr, - TubularReactor, LumpedRateModelWithPores, LumpedRateModelWithoutPores + TubularReactor, LumpedRateModelWithPores, LumpedRateModelWithoutPores, MCT ) length = 0.6 @@ -24,9 +25,19 @@ bed_porosity = 0.3 particle_porosity = 0.6 total_porosity = bed_porosity + (1 - bed_porosity) * particle_porosity +const_solid_volume = volume * (1 - total_porosity) +init_liquid_volume = volume * total_porosity axial_dispersion = 4.7e-7 +channel_cross_section_areas = [0.1,0.1,0.1] +exchange_matrix = np.array([ + [[0.0],[0.01],[0.0]], + [[0.02],[0.0],[0.03]], + [[0.0],[0.0],[0.0]] + ]) +flow_direction = 1 + class Test_Unit_Operation(unittest.TestCase): @@ -44,8 +55,8 @@ def create_source(self): def create_cstr(self): cstr = Cstr(self.component_system, name='test') - cstr.porosity = total_porosity - cstr.V = volume + cstr.const_solid_volume = const_solid_volume + cstr.init_liquid_volume = init_liquid_volume cstr.flow_rate = 1 @@ -60,6 +71,16 @@ def create_tubular_reactor(self): return tube + def create_MCT(self, components): + mct = MCT(ComponentSystem(components), nchannel=3, name='test') + + mct.length = length + mct.channel_cross_section_areas = channel_cross_section_areas + mct.axial_dispersion = 0 + mct.flow_direction = flow_direction + + return mct + def create_lrmwop(self): lrmwop = LumpedRateModelWithoutPores( self.component_system, name='test' @@ -127,13 +148,18 @@ def test_convection_dispersion(self): tube.axial_dispersion = 0 with self.assertRaises(ZeroDivisionError): - tube.u0(flow_rate) + tube.calculate_interstitial_velocity(flow_rate) + with self.assertRaises(ZeroDivisionError): + tube.calculate_superficial_velocity(flow_rate) + with self.assertRaises(ZeroDivisionError): tube.NTP(flow_rate) flow_rate = 2 tube.axial_dispersion = 3 - self.assertAlmostEqual(tube.u0(flow_rate), 2) - self.assertAlmostEqual(tube.t0(flow_rate), 0.5) + self.assertAlmostEqual(tube.calculate_interstitial_velocity(flow_rate), 2) + self.assertAlmostEqual(tube.calculate_interstitial_rt(flow_rate), 0.5) + self.assertAlmostEqual(tube.calculate_superficial_velocity(flow_rate), 2) + self.assertAlmostEqual(tube.calculate_superficial_rt(flow_rate), 0.5) self.assertAlmostEqual(tube.NTP(flow_rate), 1/3) tube.set_axial_dispersion_from_NTP(1/3, 2) @@ -143,8 +169,10 @@ def test_convection_dispersion(self): lrmwp.length = 1 lrmwp.bed_porosity = 0.5 lrmwp.cross_section_area = 1 - self.assertAlmostEqual(lrmwp.u0(flow_rate), 4) - self.assertAlmostEqual(lrmwp.t0(flow_rate), 0.25) + self.assertAlmostEqual(lrmwp.calculate_interstitial_velocity(flow_rate), 4) + self.assertAlmostEqual(lrmwp.calculate_interstitial_rt(flow_rate), 0.25) + self.assertAlmostEqual(lrmwp.calculate_superficial_velocity(flow_rate), 2) + self.assertAlmostEqual(lrmwp.calculate_superficial_rt(flow_rate), 0.5) def test_poly_properties(self): source = self.create_source() @@ -191,12 +219,13 @@ def test_parameters(self): cstr = self.create_cstr() parameters_expected = { 'flow_rate': np.array([1, 0, 0, 0]), - 'porosity': total_porosity, + 'init_liquid_volume': init_liquid_volume, 'flow_rate_filter': 0, 'c': [0, 0], 'q': [], - 'V': volume, + 'const_solid_volume': const_solid_volume, } + np.testing.assert_equal(parameters_expected, cstr.parameters) sec_dep_parameters_expected = { @@ -214,7 +243,67 @@ def test_parameters(self): poly_parameters, cstr.polynomial_parameters ) - self.assertEqual(cstr.required_parameters, ['V']) + self.assertEqual(cstr.required_parameters, ['init_liquid_volume']) + + + def test_MCT(self): + """ + Notes + ----- + Tests Parameters, Volumes and Attributes depending on nchannel. Should be later integrated into general testing workflow. + """ + total_porosity = 1 + + mct = self.create_MCT(1) + + mct.exchange_matrix = exchange_matrix + + parameters_expected = { + 'c': np.array([[0., 0., 0.]]), + 'axial_dispersion' : 0, + 'channel_cross_section_areas' : channel_cross_section_areas, + 'length' : length, + 'exchange_matrix': exchange_matrix, + 'flow_direction' : 1, + 'nchannel' : 3 + } + np.testing.assert_equal(parameters_expected, {key: value for key, value in mct.parameters.items() if key != 'discretization'}) + + volume = length*sum(channel_cross_section_areas) + volume_liquid = volume*total_porosity + volume_solid = (total_porosity-1)*volume + + self.assertAlmostEqual(mct.volume_liquid, volume_liquid) + self.assertAlmostEqual(mct.volume_solid, volume_solid) + + with self.assertRaises(ValueError): + mct.exchange_matrix = np.array([[ + [0.0, 0.01, 0.0], + [0.02, 0.0, 0.03], + [0.0, 0.0, 0.0] + ]]) + + mct.nchannel = 2 + with self.assertRaises(ValueError): + mct.exchange_matrix + mct.channel_cross_section_areas + + self.assertTrue(mct.nchannel*mct.component_system.n_comp == mct.c.size) + + mct2 = self.create_MCT(2) + + with self.assertRaises(ValueError): + mct2.exchange_matrix = np.array([[ + [0.0, 0.01, 0.0], + [0.02, 0.0, 0.03], + [0.0, 0.0, 0.0] + ], + + [ + [0.0, 0.01, 0.0], + [0.02, 0.0, 0.03], + [0.0, 0.0, 0.0] + ]]) if __name__ == '__main__':