From 508983ad4ff93d790b63e9531a770bc5efa241d3 Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Mon, 22 Apr 2024 11:56:39 +0200 Subject: [PATCH 01/92] Add qNParEGO Ax MOO Interface --- CADETProcess/optimization/__init__.py | 4 +-- CADETProcess/optimization/axAdapater.py | 41 ++++++++++++++++++++++--- tests/test_optimizer_behavior.py | 10 +++++- 3 files changed, 48 insertions(+), 7 deletions(-) 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..17d47635 100644 --- a/CADETProcess/optimization/axAdapater.py +++ b/CADETProcess/optimization/axAdapater.py @@ -15,6 +15,7 @@ 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 @@ -32,9 +33,10 @@ ParallelizationBackendBase ) __all__ = [ - 'GPEI', - 'BotorchModular', - 'NEHVI', + "GPEI", + "BotorchModular", + "NEHVI", + "qNParEGO", ] @@ -54,7 +56,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, @@ -550,3 +551,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 = 'FixedNoiseGP' + 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/tests/test_optimizer_behavior.py b/tests/test_optimizer_behavior.py index 3129f822..4fd8408e 100644 --- a/tests/test_optimizer_behavior.py +++ b/tests/test_optimizer_behavior.py @@ -8,7 +8,8 @@ SLSQP, U_NSGA3, GPEI, - NEHVI + NEHVI, + qNParEGO ) @@ -106,6 +107,12 @@ class NEHVI(NEHVI): n_max_evals = 60 +class qNParEGO(qNParEGO): + n_init_evals = 50 + early_stopping_improvement_bar = 1e-4 + early_stopping_improvement_window = 10 + n_max_evals = 60 + # ========================= # Test problem factory # ========================= @@ -139,6 +146,7 @@ def optimization_problem(request): U_NSGA3, GPEI, NEHVI, + qNParEGO ]) def optimizer(request): return request.param() From fdb7dcfa2c3ed1fea770e77b7138e36435cbbc47 Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Mon, 22 Apr 2024 14:06:24 +0200 Subject: [PATCH 02/92] Relax tolerance for MOO convergence test --- tests/test_optimizer_behavior.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_optimizer_behavior.py b/tests/test_optimizer_behavior.py index 4fd8408e..2e4cda58 100644 --- a/tests/test_optimizer_behavior.py +++ b/tests/test_optimizer_behavior.py @@ -38,7 +38,7 @@ 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 @@ -111,7 +111,7 @@ class qNParEGO(qNParEGO): n_init_evals = 50 early_stopping_improvement_bar = 1e-4 early_stopping_improvement_window = 10 - n_max_evals = 60 + n_max_evals = 70 # ========================= # Test problem factory From 69a547815aa3a4463cb8510b14e91084ca602e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Sun, 21 Apr 2024 14:57:36 +0200 Subject: [PATCH 03/92] Unify calling evaluation functions for individuals and populations Previously, there were two interfaces in the `OptimizationProblem` for calling evaluation functions (e.g. objectives): one for evaluating individuals, and one for populations. To simplify the code base, these two methods were now unified. To ensure backward compatibility, a 1D-Array is returned if a single individual is passed to the function. --- CADETProcess/optimization/axAdapater.py | 6 +- .../optimization/optimizationProblem.py | 624 ++++++++---------- CADETProcess/optimization/optimizer.py | 4 +- CADETProcess/optimization/pymooAdapter.py | 6 +- .../optimization/optimization_problem.md | 27 +- tests/test_optimization_problem.py | 4 + 6 files changed, 314 insertions(+), 357 deletions(-) diff --git a/CADETProcess/optimization/axAdapater.py b/CADETProcess/optimization/axAdapater.py index 17d47635..bfc5360e 100644 --- a/CADETProcess/optimization/axAdapater.py +++ b/CADETProcess/optimization/axAdapater.py @@ -105,7 +105,7 @@ 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, @@ -117,7 +117,7 @@ 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( @@ -313,7 +313,7 @@ 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 + nonlincon_cv_fun = op.evaluate_nonlinear_constraints_violation CV = nonlincon_cv_fun(X, untransform=True) else: G = None diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index 7b77c4ce..71d313c9 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 NoReturn, Any 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 ) @@ -149,12 +152,27 @@ def wrapper(self, x, *args, get_dependent_values=False, **kwargs): return wrapper def ensures2d(func): - """Make sure population is ndarray with ndmin=2.""" + """Decorate function to 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( + 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 @@ -661,18 +679,74 @@ def set_variables(self, x, evaluation_objects=-1): for variable, value in zip(self.variables, values): 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() + + return results - This function iterates over all functions in eval_funs (e.g. objectives). - To parallelize this, use _evaluate_population + 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 +765,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,52 +778,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. @@ -1029,75 +1057,54 @@ def add_objective( ) self._objectives.append(objective) + @ensures2d @untransforms @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 def objective_jacobian(self, x, ensure_minimization=False, dx=1e-3): @@ -1285,156 +1292,117 @@ 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. + 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. + 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. + return G_transformed - bounds_transformed - See Also - -------- - add_nonlinear_constraint - evaluate_nonlinear_constraints_violation - evaluate_nonlinear_constraints - evaluate_nonlinear_constraints_population - _evaluate_individual - _evaluate - - """ - 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): @@ -1598,74 +1566,43 @@ 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 | 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 | npt.ArrayLike + Population to be evaluated. + 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}') - - 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. + if isinstance(population, np.ndarray): + population = self.create_population(population) - See Also - -------- - add_callback - evaluate_callbacks - """ if parallelization_backend is None: parallelization_backend = SequentialBackend() @@ -1674,15 +1611,33 @@ 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_transformed, callback, force, untransform=True) + except CADETProcessError: + self.logger.warning( + f'Evaluation of {callback} failed at {ind.x}.' + ) + + 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 +1748,54 @@ def add_meta_score( ) self._meta_scores.append(meta_score) + @ensures2d @untransforms @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 +1853,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 +1873,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.') @@ -2638,12 +2582,13 @@ def transform(self, x_independent): return transform.reshape(x_independent.shape) - 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 + x_transformed : npt.ArrayLike Optimization variables in transformed parameter space. Returns @@ -2999,40 +2944,39 @@ def create_individual( 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, + X: npt.ArrayLike, + F: npt.ArrayLike = None, + G: npt.ArrayLike | None = None, + M: npt.ArrayLike | None = None, + F_min: npt.ArrayLike | None = None, + CV: npt.ArrayLike | None = None, cv_tol: float = 0., - M_min: np.ndarray | 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 + G : npt.ArrayLike Nonlinear constraint values. - M : np.ndarray + M : npt.ArrayLike Meta score values. - F_min : np.ndarray + F_min : npt.ArrayLike Minimized objective values. - CV : np.ndarray + CV : npt.ArrayLike Nonlinear constraints violation. cv_tol : float Tolerance for constraints violation. - M_min : np.ndarray + M_min : npt.ArrayLike Minimized meta score values. Returns @@ -3040,6 +2984,8 @@ def create_population( Population The newly created population. """ + X = np.array(X, ndmin=2) + if F is None: F = len(X) * [None] else: diff --git a/CADETProcess/optimization/optimizer.py b/CADETProcess/optimization/optimizer.py index f2239a0c..87a0aab4 100644 --- a/CADETProcess/optimization/optimizer.py +++ b/CADETProcess/optimization/optimizer.py @@ -429,7 +429,7 @@ def _create_population(self, X_transformed, F, F_min, G, CV): CV = np.array(CV, 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, @@ -511,7 +511,7 @@ def _evaluate_callbacks(self, current_generation, sub_dir=None): 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, diff --git a/CADETProcess/optimization/pymooAdapter.py b/CADETProcess/optimization/pymooAdapter.py index d63543b4..c126ce96 100644 --- a/CADETProcess/optimization/pymooAdapter.py +++ b/CADETProcess/optimization/pymooAdapter.py @@ -237,7 +237,7 @@ 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, ensure_minimization=True, @@ -246,12 +246,12 @@ def _evaluate(self, X, out, *args, **kwargs): 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, parallelization_backend=self.parallelization_backend, ) - CV = opt.evaluate_nonlinear_constraints_violation_population( + CV = opt.evaluate_nonlinear_constraints_violation( X, untransform=True, parallelization_backend=self.parallelization_backend, 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/tests/test_optimization_problem.py b/tests/test_optimization_problem.py index a72420ee..bde6ef60 100644 --- a/tests/test_optimization_problem.py +++ b/tests/test_optimization_problem.py @@ -1304,6 +1304,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) From 1a4debbc7acf8b947c743bb329cc147e62e331ac Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Mon, 29 Apr 2024 15:45:58 +0200 Subject: [PATCH 04/92] Ensure callback dirs are created for final callback --- CADETProcess/optimization/optimizer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/CADETProcess/optimization/optimizer.py b/CADETProcess/optimization/optimizer.py index 87a0aab4..3cc004ae 100644 --- a/CADETProcess/optimization/optimizer.py +++ b/CADETProcess/optimization/optimizer.py @@ -505,6 +505,7 @@ 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 From 9720966d7abd460dd4c211d4787b7b58a587844c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 24 Nov 2023 09:31:36 +0100 Subject: [PATCH 05/92] Update docstrings --- CADETProcess/dataStructure/cache.py | 14 ++++++ CADETProcess/fractionation/fractionator.py | 21 +++++--- CADETProcess/fractionation/fractions.py | 57 ++++++++++++++++------ 3 files changed, 68 insertions(+), 24 deletions(-) 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/fractionation/fractionator.py b/CADETProcess/fractionation/fractionator.py index 0f1c00fd..f77ed276 100644 --- a/CADETProcess/fractionation/fractionator.py +++ b/CADETProcess/fractionation/fractionator.py @@ -21,11 +21,16 @@ 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 +141,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): @@ -329,7 +334,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 +441,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 ------- diff --git a/CADETProcess/fractionation/fractions.py b/CADETProcess/fractionation/fractions.py index 8475106a..aa81bd48 100644 --- a/CADETProcess/fractionation/fractions.py +++ b/CADETProcess/fractionation/fractions.py @@ -16,27 +16,31 @@ 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() - 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'] @property def n_comp(self): @@ -106,6 +110,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 +135,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 +148,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. From 83fff8fa9bf23016b822d0c1f80ace9dab1ce9d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 24 Nov 2023 09:32:16 +0100 Subject: [PATCH 06/92] Add method to create fraction --- CADETProcess/fractionation/fractionator.py | 6 +++--- CADETProcess/solution.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/CADETProcess/fractionation/fractionator.py b/CADETProcess/fractionation/fractionator.py index f77ed276..3b14e001 100644 --- a/CADETProcess/fractionation/fractionator.py +++ b/CADETProcess/fractionation/fractionator.py @@ -449,9 +449,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/solution.py b/CADETProcess/solution.py index 90ee8add..47c91dbe 100755 --- a/CADETProcess/solution.py +++ b/CADETProcess/solution.py @@ -450,6 +450,28 @@ def integral(self, start=None, end=None): 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 + + """ + from CADETProcess.fractionation import Fraction + mass = self.fraction_mass(start, end) + volume = self.fraction_volume(start, end) + return Fraction(mass, volume) + def fraction_mass(self, start=None, end=None): """Component mass in a fraction interval From 8cc02e175addc6ef9ad93c57d08722e4e88af165 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 19 Jan 2024 14:10:03 +0100 Subject: [PATCH 07/92] Add `start` and `end` times to `Fraction` --- CADETProcess/fractionation/fractions.py | 4 +++- CADETProcess/solution.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CADETProcess/fractionation/fractions.py b/CADETProcess/fractionation/fractions.py index aa81bd48..0086ac8c 100644 --- a/CADETProcess/fractionation/fractions.py +++ b/CADETProcess/fractionation/fractions.py @@ -39,8 +39,10 @@ class Fraction(Structure): mass = Vector() volume = UnsignedFloat() + start = UnsignedFloat() + end = UnsignedFloat() - _parameters = ['mass', 'volume'] + _parameters = ['mass', 'volume', 'start', 'end'] @property def n_comp(self): diff --git a/CADETProcess/solution.py b/CADETProcess/solution.py index 47c91dbe..7149e745 100755 --- a/CADETProcess/solution.py +++ b/CADETProcess/solution.py @@ -470,7 +470,7 @@ def create_fraction(self, start=None, end=None): from CADETProcess.fractionation import Fraction mass = self.fraction_mass(start, end) volume = self.fraction_volume(start, end) - return Fraction(mass, volume) + return Fraction(mass, volume, start, end) def fraction_mass(self, start=None, end=None): """Component mass in a fraction interval From 159b374620e1d47c75cbc7301e412b0868b45ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 19 Jan 2024 14:44:19 +0100 Subject: [PATCH 08/92] Specify and validate reference type for difference metrics --- CADETProcess/comparison/difference.py | 38 +++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/CADETProcess/comparison/difference.py b/CADETProcess/comparison/difference.py index ff8c6c46..b35eefba 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 from .shape import pearson, pearson_offset from .peaks import find_peaks, find_breakthroughs @@ -74,7 +75,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 +98,8 @@ class DifferenceBase(MetricBase): If True, normalize data. The default is False. """ + _valid_references = () + def __init__( self, reference, @@ -113,7 +116,7 @@ def __init__( Parameters ---------- - reference : ReferenceIO + reference : ReferenceBase Reference used for calculating difference metric. components : {str, list}, optional Solution components to be considered. @@ -165,8 +168,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: @@ -321,6 +327,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 +356,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 +367,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 +385,8 @@ class Norm(DifferenceBase): The order of the norm. """ + _valid_references = (SolutionIO, ReferenceIO) + order = UnsignedInteger() def _evaluate(self, solution): @@ -398,6 +412,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 +434,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 +480,8 @@ class Shape(DifferenceBase): """ + _valid_references = (SolutionIO, ReferenceIO) + @wraps(DifferenceBase.__init__) def __init__( self, *args, @@ -645,6 +665,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 +759,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 +847,8 @@ class BreakthroughHeight(DifferenceBase): """ + _valid_references = (SolutionIO, ReferenceIO) + @wraps(DifferenceBase.__init__) def __init__(self, *args, normalize_metrics=True, **kwargs): """Initialize BreakthroughHeight metric. @@ -874,6 +900,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): """ From 11c59f43010ab38863d28b3266891f6f87c5e59a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Sat, 20 Jan 2024 09:17:01 +0100 Subject: [PATCH 09/92] Add classes to __all__ --- CADETProcess/fractionation/fractionator.py | 3 +++ CADETProcess/fractionation/fractions.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/CADETProcess/fractionation/fractionator.py b/CADETProcess/fractionation/fractionator.py index 3b14e001..1a2c8d4a 100644 --- a/CADETProcess/fractionation/fractionator.py +++ b/CADETProcess/fractionation/fractionator.py @@ -18,6 +18,9 @@ from CADETProcess.fractionation.fractions import Fraction, FractionPool +__all__ = ["Fractionator"] + + class Fractionator(EventHandler): """Class for Chromatogram Fractionation. diff --git a/CADETProcess/fractionation/fractions.py b/CADETProcess/fractionation/fractions.py index 0086ac8c..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. From 4d7fc10d9cd501825877bdb9015783c2b1e9095d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Mon, 22 Jan 2024 12:00:32 +0100 Subject: [PATCH 10/92] Add `only_transforms_solution` flag --- CADETProcess/comparison/difference.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CADETProcess/comparison/difference.py b/CADETProcess/comparison/difference.py index b35eefba..8c101c47 100644 --- a/CADETProcess/comparison/difference.py +++ b/CADETProcess/comparison/difference.py @@ -109,6 +109,7 @@ def __init__( start=None, end=None, transform=None, + only_transforms_array=True, resample=True, smooth=False, normalize=False): @@ -131,6 +132,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 @@ -146,6 +149,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 @@ -247,7 +251,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 From a8ed7367c493b43b3a02e35ea6f00ab72dbfe2ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Mon, 22 Jan 2024 12:00:53 +0100 Subject: [PATCH 11/92] Only resample if flag is set --- CADETProcess/comparison/difference.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CADETProcess/comparison/difference.py b/CADETProcess/comparison/difference.py index 8c101c47..e7e055d9 100644 --- a/CADETProcess/comparison/difference.py +++ b/CADETProcess/comparison/difference.py @@ -231,11 +231,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: From d20d9cdebc9f0e58a9a6cafe74f8692ab42e99e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Sat, 20 Jan 2024 09:17:14 +0100 Subject: [PATCH 12/92] Formatting --- CADETProcess/simulationResults.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CADETProcess/simulationResults.py b/CADETProcess/simulationResults.py index 1306e724..3088be13 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 ---------- From 52e33bf346e9fa2908cfdd8e0c9b9015df6c15c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Sat, 2 Mar 2024 13:02:09 +0100 Subject: [PATCH 13/92] Fix typo --- CADETProcess/comparison/comparator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CADETProcess/comparison/comparator.py b/CADETProcess/comparison/comparator.py index 15343efc..3d8d5938 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) From a59372a58c0a5468750adcd404b82c0b39716057 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 19 Jan 2024 14:47:59 +0100 Subject: [PATCH 14/92] Add FractionationReference --- CADETProcess/reference.py | 52 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 3 deletions(-) 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) From 8f0faf63109e361b75614ca6ed05d48d930e22d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 19 Jan 2024 14:47:44 +0100 Subject: [PATCH 15/92] Add FractionationSSE --- CADETProcess/comparison/difference.py | 61 ++++++++++++++++++++++++++- tests/test_difference.py | 48 +++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/CADETProcess/comparison/difference.py b/CADETProcess/comparison/difference.py index e7e055d9..f19feea0 100644 --- a/CADETProcess/comparison/difference.py +++ b/CADETProcess/comparison/difference.py @@ -11,7 +11,7 @@ from CADETProcess.dataStructure import UnsignedInteger from CADETProcess.solution import SolutionIO, slice_solution from CADETProcess.metric import MetricBase -from CADETProcess.reference import ReferenceIO +from CADETProcess.reference import ReferenceIO, FractionationReference from .shape import pearson, pearson_offset from .peaks import find_peaks, find_breakthroughs @@ -25,6 +25,7 @@ 'Shape', 'PeakHeight', 'PeakPosition', 'BreakthroughHeight', 'BreakthroughPosition', + 'FractionationSSE', ] @@ -972,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/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() From 6bbb6c8150ca3e209ab1f8ca58529e8b3d2175e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= <20299934+schmoelder@users.noreply.github.com> Date: Wed, 19 Jun 2024 10:10:33 +0200 Subject: [PATCH 16/92] Fix add_concentration_profile (#140) Co-authored-by: r.jaepel --- CADETProcess/processModel/process.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/CADETProcess/processModel/process.py b/CADETProcess/processModel/process.py index 8efb48cf..04db23dc 100644 --- a/CADETProcess/processModel/process.py +++ b/CADETProcess/processModel/process.py @@ -552,11 +552,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 +563,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] From 12d862b8d5001ffddfd78b0377d53afd0d95e64b Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Wed, 22 May 2024 10:17:43 +0200 Subject: [PATCH 17/92] Always inherit cadet path Previously, the cadet path set in Cadet(install_path="path") was not inherited into cadet instances created from the run() method. --- CADETProcess/simulator/cadetAdapter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index 1bcd2782..bab6644b 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -460,8 +460,7 @@ 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.cadet_path = self.cadet_path return cadet def save_to_h5(self, process, file_path): From 19e54575655bdcaa3379c832808e2643a0a180cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 15 Feb 2023 12:24:37 +0100 Subject: [PATCH 18/92] Add method to calculate volumetric flow rate from velocity. --- CADETProcess/processModel/unitOperation.py | 133 +++++++++++++-------- 1 file changed, 82 insertions(+), 51 deletions(-) diff --git a/CADETProcess/processModel/unitOperation.py b/CADETProcess/processModel/unitOperation.py index 0354156d..8af49ecd 100644 --- a/CADETProcess/processModel/unitOperation.py +++ b/CADETProcess/processModel/unitOperation.py @@ -455,8 +455,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: @@ -499,39 +497,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 +526,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 ------- + 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 + -------- + calculate_superficial_rt + calculate_interstitial_velocity + NTP + + """ + 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 + Interstitial flow velocity. + + Returns + ------- + Q : float + Volumetric flow rate. See Also -------- + calculate_interstitial_velocity t0 - NTP """ - return self.length/self.t0(flow_rate) + return u0 * self.cross_section_area_interstitial def NTP(self, flow_rate): r"""Number of theoretical plates. @@ -624,7 +658,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 +687,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): @@ -823,9 +859,6 @@ 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 @@ -958,8 +991,6 @@ 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 From 3356cde96c9a0298fc24d57261cebe579d45927f Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Wed, 20 Mar 2024 13:37:48 +0100 Subject: [PATCH 19/92] Rename .t0 to .calculate_interstitial_rt in Cstr Fix and extend tests about .calculate_interstitial_rt/velocity --- CADETProcess/processModel/unitOperation.py | 8 ++++---- tests/test_unit_operation.py | 17 ++++++++++++----- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CADETProcess/processModel/unitOperation.py b/CADETProcess/processModel/unitOperation.py index 8af49ecd..4b556459 100644 --- a/CADETProcess/processModel/unitOperation.py +++ b/CADETProcess/processModel/unitOperation.py @@ -634,7 +634,7 @@ def calculate_flow_rate_from_velocity(self, u0): See Also -------- calculate_interstitial_velocity - t0 + calculate_interstitial_rt """ return u0 * self.cross_section_area_interstitial @@ -1130,13 +1130,13 @@ def volume_solid(self): """float: Volume of the solid phase.""" return (1 - self.porosity) * self.V - 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 ------- diff --git a/tests/test_unit_operation.py b/tests/test_unit_operation.py index a40ecca4..8dbfc986 100644 --- a/tests/test_unit_operation.py +++ b/tests/test_unit_operation.py @@ -127,13 +127,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 +148,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() From 6fd5a4494d2aa82b2bb67da785e506e7248bcd43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Mon, 17 Jun 2024 13:43:07 +0200 Subject: [PATCH 20/92] Do not modify LD_LIBRARY_PATH when setting install_path Previously, this was required because CADET-Core was not setting the PATH correctly. Now, this can lead to inconsistent behaviour. Note, this requires CADET>4.4.0 --- CADETProcess/simulator/cadetAdapter.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index bab6644b..8ae8696e 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -279,15 +279,6 @@ def install_path(self, install_path): 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. From 618fb1ff8abbd0568ebcc70678491e021965e3cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 20 Mar 2024 13:49:43 +0100 Subject: [PATCH 21/92] Adapt to new DG interface in CADET-Core --- CADETProcess/processModel/discretization.py | 148 ++++++-------------- CADETProcess/simulator/cadetAdapter.py | 2 - 2 files changed, 44 insertions(+), 106 deletions(-) diff --git a/CADETProcess/processModel/discretization.py b/CADETProcess/processModel/discretization.py index aeebfec3..4db7aed2 100644 --- a/CADETProcess/processModel/discretization.py +++ b/CADETProcess/processModel/discretization.py @@ -14,7 +14,7 @@ class for all other classes in this module and defines some common parameters. """ from CADETProcess.dataStructure import Structure from CADETProcess.dataStructure import ( - Bool, Switch, + Constant, Bool, Switch, RangedInteger, UnsignedInteger, UnsignedFloat, SizedRangedList ) @@ -43,7 +43,6 @@ class DiscretizationParametersBase(Structure): Consistency solver parameters for Cadet. """ - _dimensionality = [] def __init__(self): @@ -121,12 +120,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 +136,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 +155,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 +170,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 +212,7 @@ class LRMPDiscretizationFV(DiscretizationParametersBase): """ + spatial_method = Constant(value='FV') ncol = UnsignedInteger(default=100) par_geom = Switch( @@ -234,7 +229,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 +241,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 +251,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 +264,8 @@ class LRMPDiscretizationDG(DGMixin): """ - ncol = UnsignedInteger(default=16) + spatial_method = Constant(value='DG') + nelem = UnsignedInteger(default=16) par_geom = Switch( default='SPHERE', @@ -295,28 +273,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 +350,7 @@ class GRMDiscretizationFV(DiscretizationParametersBase): """ + spatial_method = Constant(value='FV') ncol = UnsignedInteger(default=100) npar = UnsignedInteger(default=5) @@ -408,7 +379,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 +405,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 +418,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 +441,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 +460,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,17 +480,17 @@ 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) class WenoParameters(Structure): diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index 8ae8696e..edb08909 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -924,8 +924,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): From 8ce41f3ca8bb9204caf59e5cfbbfd87d76bc3098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Mon, 17 Jun 2024 12:01:59 +0200 Subject: [PATCH 22/92] Fix using minute for x-Axis Recently, the option to (not) plot the time axis using minutes was introduced. This commit fixes some methods that were not properly implemented. Moreover, the name of the flag was changed from `use_minutes` to `x_axis_in_minutes` to make clear that only plotting is affected and not other parameter values (e.g. start and end times). --- CADETProcess/comparison/comparator.py | 18 +- CADETProcess/dynamicEvents/event.py | 25 +- CADETProcess/dynamicEvents/section.py | 13 +- CADETProcess/fractionation/fractionator.py | 20 +- CADETProcess/solution.py | 257 +++++++++++++-------- CADETProcess/transform.py | 3 +- 6 files changed, 209 insertions(+), 127 deletions(-) diff --git a/CADETProcess/comparison/comparator.py b/CADETProcess/comparison/comparator.py index 3d8d5938..79cfcbe0 100644 --- a/CADETProcess/comparison/comparator.py +++ b/CADETProcess/comparison/comparator.py @@ -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/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..d50fe45e 100644 --- a/CADETProcess/dynamicEvents/section.py +++ b/CADETProcess/dynamicEvents/section.py @@ -6,7 +6,6 @@ import scipy from matplotlib.axes import Axes - from CADETProcess import CADETProcessError from CADETProcess.dataStructure import Structure from CADETProcess.dataStructure import NdPolynomial @@ -407,15 +406,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 +426,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 +434,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/fractionation/fractionator.py b/CADETProcess/fractionation/fractionator.py index 1a2c8d4a..c3e32701 100644 --- a/CADETProcess/fractionation/fractionator.py +++ b/CADETProcess/fractionation/fractionator.py @@ -230,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: @@ -241,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 @@ -268,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) @@ -296,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 diff --git a/CADETProcess/solution.py b/CADETProcess/solution.py index 7149e745..85f36a78 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 @@ -536,32 +537,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 ------- @@ -583,16 +590,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) @@ -604,24 +615,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 @@ -631,6 +647,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 @@ -673,11 +691,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 @@ -787,23 +813,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 @@ -811,6 +840,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. @@ -845,16 +876,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) @@ -878,7 +913,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. @@ -923,7 +958,7 @@ def plot_at_time( @plotting.create_and_save_figure def plot_at_position( self, - z, + z: float, components=None, layout=None, ax=None, @@ -963,11 +998,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) @@ -1212,23 +1257,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 @@ -1236,6 +1284,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. @@ -1268,16 +1318,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) @@ -1288,24 +1342,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. @@ -1406,35 +1462,54 @@ def solution_shape(self): return (self.nt, 1) @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) 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. From 1c9251fca84fe8ca2298ce5a1f2d0f1d5039160d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Mon, 17 Jun 2024 12:02:25 +0200 Subject: [PATCH 23/92] Freeze discretization attributes --- CADETProcess/processModel/discretization.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CADETProcess/processModel/discretization.py b/CADETProcess/processModel/discretization.py index 4db7aed2..1c389836 100644 --- a/CADETProcess/processModel/discretization.py +++ b/CADETProcess/processModel/discretization.py @@ -12,7 +12,7 @@ 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 ( Constant, Bool, Switch, RangedInteger, UnsignedInteger, UnsignedFloat, @@ -30,6 +30,7 @@ class for all other classes in this module and defines some common parameters. ] +@frozen_attributes class DiscretizationParametersBase(Structure): """Base class for storing discretization parameters. @@ -493,6 +494,7 @@ def par_disc_vector_length(self): return self.par_nelem * (self.par_polydeg + 1) +@frozen_attributes class WenoParameters(Structure): """Discretization parameters for the WENO scheme. @@ -528,6 +530,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. From 4f1671b8b231b2558e52b5b1e9a3709f47d50dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Mon, 17 Jun 2024 12:02:36 +0200 Subject: [PATCH 24/92] Update docstrings --- CADETProcess/processModel/discretization.py | 3 --- CADETProcess/solution.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/CADETProcess/processModel/discretization.py b/CADETProcess/processModel/discretization.py index 1c389836..a050d471 100644 --- a/CADETProcess/processModel/discretization.py +++ b/CADETProcess/processModel/discretization.py @@ -104,9 +104,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 diff --git a/CADETProcess/solution.py b/CADETProcess/solution.py index 85f36a78..64ed5308 100755 --- a/CADETProcess/solution.py +++ b/CADETProcess/solution.py @@ -513,7 +513,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 ---------- From 7ddabeb5966dab8eb859dd53fd4011db09c01017 Mon Sep 17 00:00:00 2001 From: "a.berger" Date: Wed, 22 May 2024 13:59:40 +0200 Subject: [PATCH 25/92] Fix incorrect declaration in AntiLangmuir as optional --- CADETProcess/processModel/binding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CADETProcess/processModel/binding.py b/CADETProcess/processModel/binding.py index 1f7c6a98..8d98458e 100644 --- a/CADETProcess/processModel/binding.py +++ b/CADETProcess/processModel/binding.py @@ -442,7 +442,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 unsigned floats. Anti-Langmuir coefficients. Length depends on `n_comp`. """ From cdc11b9671a82ca0cdd211ac0a24ee0bb2a897d2 Mon Sep 17 00:00:00 2001 From: "a.berger" Date: Mon, 10 Jun 2024 11:02:21 +0200 Subject: [PATCH 26/92] Change AntiLangmuir coefficient from SizedUnsignedList to SizedList --- CADETProcess/processModel/binding.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CADETProcess/processModel/binding.py b/CADETProcess/processModel/binding.py index 8d98458e..57c74171 100644 --- a/CADETProcess/processModel/binding.py +++ b/CADETProcess/processModel/binding.py @@ -442,7 +442,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. + antilangmuir : list of {-1, 1}. Anti-Langmuir coefficients. Length depends on `n_comp`. """ @@ -450,7 +450,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', From 6150667bae410da31f6eb228faabfbe17adb48a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= <20299934+schmoelder@users.noreply.github.com> Date: Tue, 25 Jun 2024 14:36:00 +0200 Subject: [PATCH 27/92] Raise exception when adding connections to Inlets or from Outlets Co-authored-by: daklauss --- CADETProcess/processModel/flowSheet.py | 5 +++++ tests/test_flow_sheet.py | 31 +++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/CADETProcess/processModel/flowSheet.py b/CADETProcess/processModel/flowSheet.py index 29fdd4ea..fa5985a3 100644 --- a/CADETProcess/processModel/flowSheet.py +++ b/CADETProcess/processModel/flowSheet.py @@ -344,8 +344,13 @@ 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 destination in self.connections[origin].destinations: raise CADETProcessError('Connection already exists') diff --git a/tests/test_flow_sheet.py b/tests/test_flow_sheet.py index 44c32fc1..74d009de 100644 --- a/tests/test_flow_sheet.py +++ b/tests/test_flow_sheet.py @@ -261,7 +261,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 @@ -552,6 +552,35 @@ def test_output_state(self): } ) + 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) + class TestCstrFlowRate(unittest.TestCase): """ From e706472bb3c68307fd159ead6d7e4cd483d4ca78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Tue, 25 Jun 2024 17:15:04 +0200 Subject: [PATCH 28/92] Fix header level in documentation for unit operation --- docs/source/user_guide/process_model/unit_operation.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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`. From 2e6a7e3b99698837c98fb4a9f7defbcfbdaa0d70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 26 Jun 2024 11:35:24 +0200 Subject: [PATCH 29/92] Fix bug when adding linear constraints --- CADETProcess/optimization/optimizationProblem.py | 2 +- tests/test_optimization_problem.py | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index 71d313c9..c8a9273f 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -2047,7 +2047,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( diff --git a/tests/test_optimization_problem.py b/tests/test_optimization_problem.py index bde6ef60..56397abc 100644 --- a/tests/test_optimization_problem.py +++ b/tests/test_optimization_problem.py @@ -712,19 +712,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 +738,8 @@ def test_add_linear_constraints(self): with self.assertRaises(CADETProcessError): self.optimization_problem.add_linear_constraint('var_0', []) + + def test_initial_values(self): x0_chebyshev_expected = [1/3, 2/3] x0_chebyshev = self.optimization_problem.get_chebyshev_center( From 39402fe2c1c37b454e7da0980267f240b56f0bc6 Mon Sep 17 00:00:00 2001 From: "Lanzrath, Hannah" Date: Wed, 3 Jul 2024 16:28:32 +0200 Subject: [PATCH 30/92] Fix plot_at_position Updates and Fixes plot_at_position by adding start, end and x_axis_in_minutes as parameters in solution.py --- CADETProcess/solution.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/CADETProcess/solution.py b/CADETProcess/solution.py index 64ed5308..1ec57122 100755 --- a/CADETProcess/solution.py +++ b/CADETProcess/solution.py @@ -959,9 +959,12 @@ def plot_at_time( def plot_at_position( self, z: float, - components=None, - layout=None, - ax=None, + 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, ): @@ -971,10 +974,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. From 0f900b395052c627043908a0338da876110dae16 Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Mon, 8 Jul 2024 16:21:27 +0200 Subject: [PATCH 31/92] Fix divide by zero error in pearsonr_mat The error was caused by lower floating point division in the numba.njit optimized code. We now check if r_x_den * r_y_den is != zero and only then calculate the real result. --- CADETProcess/comparison/shape.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 From ead85ce3a48a9782a71d802ec1fa63258f38a275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 21 Jun 2024 16:53:26 +0200 Subject: [PATCH 32/92] Avoid duplicate entries in user_solution_times --- CADETProcess/simulator/cadetAdapter.py | 5 +++-- CADETProcess/simulator/simulator.py | 8 +++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index edb08909..b9583ecd 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -563,9 +563,9 @@ def get_simulation_results( flow_in = process.flow_rate_timelines[unit.name].total_in flow_out = process.flow_rate_timelines[unit.name].total_out + start = 0 for cycle in range(self.n_cycles): - start = cycle * len(time) - end = (cycle + 1) * len(time) + end = start + len(time) if 'solution_inlet' in unit_solution.keys(): sol_inlet = unit_solution.solution_inlet[start:end, :] @@ -631,6 +631,7 @@ def get_simulation_results( sol_volume ) ) + start = end - 1 solution = Dict(solution) 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) From 736a4b4644483c8eb57bd36c02a7fa6197e606d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Sat, 9 Dec 2023 16:08:52 +0100 Subject: [PATCH 33/92] Improve tearDown after tests Clean up results and tmp directories after running tests. --- tests/test_optimization_integration.py | 29 +++++++++++++++++++------- tests/test_optimization_problem.py | 12 ++++++++--- tests/test_parallelization_adapter.py | 5 ++++- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/tests/test_optimization_integration.py b/tests/test_optimization_integration.py index 4d0d8a0a..c721cd0a 100644 --- a/tests/test_optimization_integration.py +++ b/tests/test_optimization_integration.py @@ -17,18 +17,23 @@ class TestBatchElutionOptimizationSingleObjective(unittest.TestCase): def setUp(self): + self.tearDown() + 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) def test_single_core(self): if not test_batch_elution_single_objective_single_core: @@ -56,7 +61,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 +78,23 @@ def test_multi_core(self): class TestBatchElutionOptimizationMultiObjective(unittest.TestCase): def setUp(self): + self.tearDown() + 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) @unittest.skipIf(__name__ != "__main__", "Only run test if test is run as __main__") def test_optimization(self): @@ -93,7 +103,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,23 +123,27 @@ def test_optimization(self): class TestFitColumnParameters(unittest.TestCase): def setUp(self): + self.tearDown() + 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) def test_optimization(self): @@ -138,7 +152,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_fit_column_parameters") @@ -153,7 +167,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 56397abc..31e12ec8 100644 --- a/tests/test_optimization_problem.py +++ b/tests/test_optimization_problem.py @@ -11,7 +11,7 @@ Structure, Float, List, SizedList, SizedNdArray, Polynomial, NdPolynomial ) from CADETProcess.optimization import OptimizationProblem -from tests.optimization_problem_fixtures import ( +from optimization_problem_fixtures import ( LinearConstraintsSooTestProblem2, LinearEqualityConstraintsSooTestProblem ) @@ -1146,14 +1146,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 diff --git a/tests/test_parallelization_adapter.py b/tests/test_parallelization_adapter.py index e7c4d973..9d441451 100644 --- a/tests/test_parallelization_adapter.py +++ b/tests/test_parallelization_adapter.py @@ -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): From b9e637e27d9337aa08b66eb68a8ac63a914cca1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Thu, 14 Mar 2024 11:35:57 +0100 Subject: [PATCH 34/92] Add FanoutCache --- CADETProcess/optimization/cache.py | 29 +++++++++++++------ .../optimization/optimizationProblem.py | 4 +-- CADETProcess/optimization/optimizer.py | 2 +- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/CADETProcess/optimization/cache.py b/CADETProcess/optimization/cache.py index aa5e6b08..794a7054 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 = {} diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index c8a9273f..95ae1648 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -2633,9 +2633,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.""" diff --git a/CADETProcess/optimization/optimizer.py b/CADETProcess/optimization/optimizer.py index 3cc004ae..deb874c9 100644 --- a/CADETProcess/optimization/optimizer.py +++ b/CADETProcess/optimization/optimizer.py @@ -235,7 +235,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) From b1146747a57062caa8ab85c4394bb52aa024f777 Mon Sep 17 00:00:00 2001 From: Angela Moser Date: Thu, 20 Jun 2024 13:44:47 -0400 Subject: [PATCH 35/92] Add colloidal binding model --- CADETProcess/processModel/binding.py | 94 ++++++++++++++++++++++++++ CADETProcess/simulator/cadetAdapter.py | 25 +++++++ 2 files changed, 119 insertions(+) diff --git a/CADETProcess/processModel/binding.py b/CADETProcess/processModel/binding.py index 57c74171..18379441 100644 --- a/CADETProcess/processModel/binding.py +++ b/CADETProcess/processModel/binding.py @@ -39,6 +39,7 @@ 'GeneralizedIonExchange', 'HICConstantWaterActivity', 'HICWaterOnHydrophobicSurfaces', + 'MultiComponentColloidal', ] @@ -1103,3 +1104,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/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index b9583ecd..3af7ec3a 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -1633,6 +1633,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 = { From 3a177b7bcebc93283288c85a8cfd4aa1848783d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Mon, 22 Jul 2024 11:55:52 +0200 Subject: [PATCH 36/92] Add support for numpy v2.0.0 --- CADETProcess/dynamicEvents/section.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CADETProcess/dynamicEvents/section.py b/CADETProcess/dynamicEvents/section.py index d50fe45e..fdd3afcf 100644 --- a/CADETProcess/dynamicEvents/section.py +++ b/CADETProcess/dynamicEvents/section.py @@ -2,7 +2,7 @@ import warnings import numpy as np -from numpy import VisibleDeprecationWarning +from numpy.exceptions import VisibleDeprecationWarning import scipy from matplotlib.axes import Axes From e85ca8df17e950231b51efcebccdb03601448549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Mon, 5 Aug 2024 16:53:09 +0200 Subject: [PATCH 37/92] Fix typo in set_diameter methods --- CADETProcess/processModel/unitOperation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CADETProcess/processModel/unitOperation.py b/CADETProcess/processModel/unitOperation.py index 4b556459..e1563029 100644 --- a/CADETProcess/processModel/unitOperation.py +++ b/CADETProcess/processModel/unitOperation.py @@ -464,7 +464,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. @@ -862,7 +862,7 @@ def cross_section_area_interstitial(self): """ 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. @@ -995,7 +995,7 @@ def cross_section_area_interstitial(self): """ 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. From 022ecf3cec6b70da071f70749bc61868b5e9e58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 26 Jun 2024 11:35:24 +0200 Subject: [PATCH 38/92] Fix bug when adding linear equality constraints --- .../optimization/optimizationProblem.py | 2 +- tests/test_optimization_problem.py | 47 ++++++++++++++++++- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index 95ae1648..ea406eab 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -2325,7 +2325,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( diff --git a/tests/test_optimization_problem.py b/tests/test_optimization_problem.py index 31e12ec8..32ed3571 100644 --- a/tests/test_optimization_problem.py +++ b/tests/test_optimization_problem.py @@ -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) @@ -738,7 +748,40 @@ 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] From b715f5789cf50f485fcd568104f883f29e5a3e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 26 Jun 2024 12:37:40 +0200 Subject: [PATCH 39/92] Explicitly specify support for bounds --- CADETProcess/optimization/scipyAdapter.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CADETProcess/optimization/scipyAdapter.py b/CADETProcess/optimization/scipyAdapter.py index 36cec449..ec1354d1 100644 --- a/CADETProcess/optimization/scipyAdapter.py +++ b/CADETProcess/optimization/scipyAdapter.py @@ -457,6 +457,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 +480,7 @@ class NelderMead(SciPyInterface): disp : Bool, optional Set to True to print convergence messages. """ + supports_bounds = True maxiter = UnsignedInteger(default=1000) initial_simplex = None From e0ebd09cb1d61f13226922f4eb836f1ea3cbdb22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 26 Jun 2024 13:22:27 +0200 Subject: [PATCH 40/92] Return np.array for linear constraints --- CADETProcess/optimization/optimizationProblem.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index ea406eab..f78e8264 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -2160,7 +2160,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 -------- @@ -2437,7 +2437,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 -------- @@ -2446,10 +2446,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): From 8032e81936e901146d6612f83d087cf68c0603de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 26 Jun 2024 13:25:23 +0200 Subject: [PATCH 41/92] Fix comparison logic for linear equality constraints Changed comparison from `>=` to `>` for constraint violation to correctly handle edge cases. --- CADETProcess/optimization/optimizationProblem.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index f78e8264..68e6d5de 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -2551,7 +2551,7 @@ def check_linear_equality_constraints(self, x): flag = True lhs = self.evaluate_linear_equality_constraints(x) - if np.any(np.abs(lhs) >= self.eps_eq): + if np.any(np.abs(lhs) > self.eps_eq): flag = False return flag From 498f64f7b3a7b0f6d9a2b6aae4dfffefa7ceb073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 26 Jun 2024 12:38:58 +0200 Subject: [PATCH 42/92] Use common optimizer interface for tolerances --- tests/test_optimizer_behavior.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/tests/test_optimizer_behavior.py b/tests/test_optimizer_behavior.py index 2e4cda58..2824c018 100644 --- a/tests/test_optimizer_behavior.py +++ b/tests/test_optimizer_behavior.py @@ -41,9 +41,9 @@ "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_TOL = 0.0001 EXCLUDE_COMBINATIONS = [ (GPEI, Rosenbrock, @@ -76,19 +76,18 @@ def set_non_default_parameters(optimizer, problem): class TrustConstr(TrustConstr): - ftol = FTOL - xtol = XTOL - gtol = GTOL + x_tol = X_TOL + cv_tol = CV_TOL class SLSQP(SLSQP): - ftol = FTOL + x_tol = X_TOL class U_NSGA3(U_NSGA3): - ftol = FTOL - xtol = XTOL - cvtol = GTOL + f_tol = F_TOL + x_tol = X_TOL + cv_tol = CV_TOL pop_size = 100 n_max_gen = 20 # before used 100 generations --> this did not improve the fit From 407eba839ef2536b577910e8093656962770903f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Thu, 27 Jun 2024 11:48:40 +0200 Subject: [PATCH 43/92] Make optimial solution a property --- tests/optimization_problem_fixtures.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/optimization_problem_fixtures.py b/tests/optimization_problem_fixtures.py index 8da45ef6..9a0ff6d7 100644 --- a/tests/optimization_problem_fixtures.py +++ b/tests/optimization_problem_fixtures.py @@ -409,6 +409,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 +418,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 From a68557e4969062f4354dc9914507de134b323735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 26 Jun 2024 15:40:15 +0200 Subject: [PATCH 44/92] Formatting --- tests/optimization_problem_fixtures.py | 122 ++++++++++++++----------- tests/test_optimizer_behavior.py | 99 ++++++++++---------- 2 files changed, 118 insertions(+), 103 deletions(-) diff --git a/tests/optimization_problem_fixtures.py b/tests/optimization_problem_fixtures.py index 9a0ff6d7..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 @@ -501,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 @@ -581,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] @@ -596,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 @@ -668,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_optimizer_behavior.py b/tests/test_optimizer_behavior.py index 2824c018..d5b5867b 100644 --- a/tests/test_optimizer_behavior.py +++ b/tests/test_optimizer_behavior.py @@ -9,7 +9,7 @@ U_NSGA3, GPEI, NEHVI, - qNParEGO + qNParEGO, ) @@ -23,12 +23,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 @@ -46,18 +44,18 @@ CV_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": 50}), ] @@ -112,48 +110,49 @@ class qNParEGO(qNParEGO): 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"), -]) +# %% 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, - qNParEGO -]) +@pytest.fixture( + params=[ + TrustConstr, + SLSQP, + U_NSGA3, + GPEI, + NEHVI, + qNParEGO, + ] +) def optimizer(request): return request.param() -# ========================= -# Tests -# ========================= +# %% Tests def test_convergence(optimization_problem: TestProblem, optimizer: OptimizerBase): # only test problems that the optimizer can handle. The rest of the tests @@ -171,7 +170,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) @@ -209,8 +210,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? From 5d97c65d7dac215632f8f3a00b770e16c30f973a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 26 Jun 2024 18:22:42 +0200 Subject: [PATCH 45/92] Add option to (not) close cache on pruning --- CADETProcess/optimization/cache.py | 29 ++++++++++++------- .../optimization/optimizationProblem.py | 16 ++++++++-- CADETProcess/optimization/optimizer.py | 5 ++-- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/CADETProcess/optimization/cache.py b/CADETProcess/optimization/cache.py index 794a7054..96991838 100644 --- a/CADETProcess/optimization/cache.py +++ b/CADETProcess/optimization/cache.py @@ -90,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) @@ -102,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 ------- @@ -122,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) @@ -142,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) @@ -161,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/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index 68e6d5de..e27e7d68 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -2645,9 +2645,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, diff --git a/CADETProcess/optimization/optimizer.py b/CADETProcess/optimization/optimizer.py index deb874c9..0d20546c 100644 --- a/CADETProcess/optimization/optimizer.py +++ b/CADETProcess/optimization/optimizer.py @@ -588,15 +588,16 @@ 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) + # Remove old meta front entries from cache that were replaced by better ones 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) From 9174bd7f48c7d55127ffb82ed3c9d9a39f00866e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 26 Jun 2024 16:00:31 +0200 Subject: [PATCH 46/92] Do not plot figures if progress_frequency is None Plotting figures creates a major overhead during optimization. With this option set to `None` by default, only the final results are plotted. --- CADETProcess/optimization/optimizer.py | 5 +++-- tests/test_optimizer_behavior.py | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CADETProcess/optimization/optimizer.py b/CADETProcess/optimization/optimizer.py index 0d20546c..d9e23651 100644 --- a/CADETProcess/optimization/optimizer.py +++ b/CADETProcess/optimization/optimizer.py @@ -85,7 +85,7 @@ 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() @@ -577,7 +577,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) diff --git a/tests/test_optimizer_behavior.py b/tests/test_optimizer_behavior.py index d5b5867b..9e0ea4ad 100644 --- a/tests/test_optimizer_behavior.py +++ b/tests/test_optimizer_behavior.py @@ -149,7 +149,9 @@ def optimization_problem(request): ] ) def optimizer(request): - return request.param() + optimizer = request.param() + optimizer.progress_freqency = None + return optimizer # %% Tests From 16731016b8cc7b94e231d09463fcf1231d51169f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 26 Jun 2024 19:04:32 +0200 Subject: [PATCH 47/92] Use in-memory cache for testing optimizer behaviour --- tests/test_optimizer_behavior.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_optimizer_behavior.py b/tests/test_optimizer_behavior.py index 9e0ea4ad..5f116e92 100644 --- a/tests/test_optimizer_behavior.py +++ b/tests/test_optimizer_behavior.py @@ -135,7 +135,7 @@ class qNParEGO(qNParEGO): ] ) def optimization_problem(request): - return request.param() + return request.param(use_diskcache=False) @pytest.fixture( From e59d9aeed93d9fec67c46eef968bf5898d7f8b74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 26 Jun 2024 18:25:56 +0200 Subject: [PATCH 48/92] Update population size in non-default parameters --- tests/test_optimizer_behavior.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_optimizer_behavior.py b/tests/test_optimizer_behavior.py index 5f116e92..d84b796d 100644 --- a/tests/test_optimizer_behavior.py +++ b/tests/test_optimizer_behavior.py @@ -55,7 +55,8 @@ # 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}), + (U_NSGA3, NonlinearConstraintsMooTestProblem, {"pop_size": 300, "n_max_gen": 40}), + (U_NSGA3, Rosenbrock, {"pop_size": 300, "n_max_gen": 20}), ] From 76e03e8650aa63eaf4c724ba74016bffddf5dcf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Thu, 27 Jun 2024 10:41:30 +0200 Subject: [PATCH 49/92] Add NelderMead and COBYLA to optimizer tests --- CADETProcess/optimization/scipyAdapter.py | 2 +- tests/test_optimizer_behavior.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CADETProcess/optimization/scipyAdapter.py b/CADETProcess/optimization/scipyAdapter.py index ec1354d1..21cd157a 100644 --- a/CADETProcess/optimization/scipyAdapter.py +++ b/CADETProcess/optimization/scipyAdapter.py @@ -446,7 +446,7 @@ class COBYLA(SciPyInterface): disp = Bool(default=False) catol = UnsignedFloat(default=0.0002) - f_tol = tol # Alias for uniform interface + x_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 diff --git a/tests/test_optimizer_behavior.py b/tests/test_optimizer_behavior.py index d84b796d..aa985b8c 100644 --- a/tests/test_optimizer_behavior.py +++ b/tests/test_optimizer_behavior.py @@ -5,6 +5,8 @@ from CADETProcess.optimization import ( OptimizerBase, TrustConstr, + COBYLA, + NelderMead, SLSQP, U_NSGA3, GPEI, @@ -79,6 +81,16 @@ class TrustConstr(TrustConstr): cv_tol = CV_TOL +class COBYLA(COBYLA): + x_tol = X_TOL + cv_tol = CV_TOL + + +class NelderMead(NelderMead): + x_tol = X_TOL + f_tol = F_TOL + + class SLSQP(SLSQP): x_tol = X_TOL @@ -142,7 +154,9 @@ def optimization_problem(request): @pytest.fixture( params=[ TrustConstr, + COBYLA, SLSQP, + NelderMead, U_NSGA3, GPEI, NEHVI, From 0a5fba1287e881929301337eda22a56c494fcb8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 28 Jun 2024 17:22:43 +0200 Subject: [PATCH 50/92] Add type hints and update docstrings --- CADETProcess/optimization/individual.py | 53 ++++--- .../optimization/optimizationProblem.py | 6 +- CADETProcess/optimization/optimizer.py | 5 +- CADETProcess/optimization/population.py | 146 +++++++++--------- CADETProcess/optimization/results.py | 117 ++++++++------ 5 files changed, 180 insertions(+), 147 deletions(-) diff --git a/CADETProcess/optimization/individual.py b/CADETProcess/optimization/individual.py index c3229ad8..17c5f1e4 100644 --- a/CADETProcess/optimization/individual.py +++ b/CADETProcess/optimization/individual.py @@ -8,7 +8,7 @@ from CADETProcess.dataStructure import 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,6 +37,8 @@ class Individual(Structure): Attributes ---------- + id : str + UUID for individual. x : np.ndarray Variable values in untransformed space. x_transformed : np.ndarray @@ -128,11 +130,12 @@ def __init__( self.id = hash_array(self.x) @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 @@ -148,19 +151,19 @@ def is_feasible(self): 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 +171,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 +179,21 @@ def n_m(self): return len(self.m) @property - def dimensions(self): - """tuple: Individual dimensions (n_x, n_f, n_g, n_m)""" + 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 @@ -229,7 +234,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 +267,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 +295,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 +315,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 +335,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 +355,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 @@ -384,7 +394,7 @@ def to_dict(self): 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 +407,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 e27e7d68..6f0ada21 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -279,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) diff --git a/CADETProcess/optimization/optimizer.py b/CADETProcess/optimization/optimizer.py index d9e23651..a9bc512e 100644 --- a/CADETProcess/optimization/optimizer.py +++ b/CADETProcess/optimization/optimizer.py @@ -110,7 +110,7 @@ def __init__(self, *args, **kwargs): def optimize( self, - optimization_problem, + optimization_problem: OptimizationProblem, x0=None, save_results=True, results_directory=None, @@ -404,7 +404,8 @@ 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) diff --git a/CADETProcess/optimization/population.py b/CADETProcess/optimization/population.py index 598cb066..b46a3da6 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,28 +91,30 @@ 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 @@ -121,11 +124,15 @@ def contraint_labels(self): return self.individuals[0].contraint_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,82 +231,82 @@ 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 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) 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) @@ -330,44 +336,44 @@ def cv_avg(self): return np.mean(self.cv, 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 +405,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 +474,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 +513,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 +526,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 +561,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 +599,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 +711,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,11 +814,11 @@ 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 @@ -887,15 +889,15 @@ def update_individual(self, individual): 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 ------- @@ -998,7 +1000,7 @@ def to_dict(self): """ front = super().to_dict() if self.similarity_tol is not None: - front['similarity_tol'] = self.similarity_tol + front["similarity_tol"] = self.similarity_tol front['cv_tol'] = self.cv_tol return front @@ -1017,8 +1019,12 @@ 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( + cv_tol=data["cv_tol"], + 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/results.py b/CADETProcess/optimization/results.py index d8657f93..ef348b49 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 @@ -89,11 +91,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 +106,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 +117,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 +173,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 @@ -194,7 +196,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,33 +209,33 @@ 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 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 @@ -242,43 +244,43 @@ def cv(self): return self.meta_front.cv @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 +288,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 +296,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 @@ -326,12 +328,12 @@ def cv_avg_history(self): return np.array([pop.cv_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 +341,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 +349,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 +768,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 +786,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 +815,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 +846,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 +873,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. From 4b2047304a4941c0ef770a1c979b0d65d05de17e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 28 Jun 2024 17:40:03 +0200 Subject: [PATCH 51/92] Add method to evaluate bounds violation --- .../optimization/optimizationProblem.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index 6f0ada21..0032d0e1 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -1969,6 +1969,39 @@ def upper_bounds_independent_transformed(self): """ return [var._transform.ub for var in self.independent_variables] + @untransforms + @gets_dependent_values + def evaluate_bounds(self, x): + """Calculate bound violation. + + Parameters + ---------- + x : array_like + Value of the optimization variables in untransformed space. + + Returns + ------- + 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): From 061def5d4502a7bd8a2c8790a45c65df353f796f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Sat, 29 Jun 2024 08:33:40 +0200 Subject: [PATCH 52/92] Cast np.bool_ to bool --- CADETProcess/dataStructure/parameter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 32f6a29a849f308da667a7a655d25d359ba265e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 28 Jun 2024 17:39:34 +0200 Subject: [PATCH 53/92] Allow evaluation of population for getting (in)dependent values --- .../optimization/optimizationProblem.py | 80 ++++++++++--------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index 0032d0e1..54cf42bd 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -573,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 @@ -589,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) - return self.variable_values + variable_values[i, :] = 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. @@ -629,18 +635,23 @@ 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 def set_variables(self, x, evaluation_objects=-1): @@ -2589,12 +2600,13 @@ def check_linear_equality_constraints(self, x): 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 + x_independent : np.ndarray Value of the independent optimization variables in untransformed space. Returns @@ -2602,25 +2614,23 @@ def transform(self, x_independent): 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 @ensures2d - def untransform(self, x_transformed: npt.ArrayLike) -> np.ndarray: + def untransform(self, X_transformed: npt.ArrayLike) -> np.ndarray: """Untransform the optimization variables from transformed parameter space. Parameters ---------- - x_transformed : npt.ArrayLike + X_transformed : npt.ArrayLike Optimization variables in transformed parameter space. Returns @@ -2628,17 +2638,15 @@ def untransform(self, x_transformed: npt.ArrayLike) -> np.ndarray: 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): From 572e96e06acea1feeec8c580e7a0dbf20f242819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 28 Jun 2024 17:42:05 +0200 Subject: [PATCH 54/92] Add option to evaluate nonlinear constraints when checking individual --- .../optimization/optimizationProblem.py | 30 ++++++++++++++++--- CADETProcess/optimization/optimizer.py | 7 ++++- CADETProcess/optimization/pymooAdapter.py | 6 +++- 3 files changed, 37 insertions(+), 6 deletions(-) diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index 54cf42bd..94554ce8 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -2930,7 +2930,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: @@ -3181,18 +3182,29 @@ def check_config(self, ignore_linear_constraints=False): @untransforms @gets_dependent_values - def check_individual(self, x, silent=False): + def check_individual( + self, + x: npt.ArrayLike, + check_nonlinear_constraints=False, + silent: bool = False, + ) -> bool: """Check if individual is valid. Parameters ---------- x : array_like Value of the optimization variables in untransformed space. + check_nonlinear_constraints : bool, optional + if True, also check nonlinear constraints. The default is True. + Note, depending on the nonlinear constraints, this can be an expensive + operations. + 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 @@ -3201,15 +3213,25 @@ def check_individual(self, x, silent=False): if not silent: warnings.warn("Individual does not satisfy bounds.") flag = False + if not self.check_linear_constraints(x): if not silent: warnings.warn("Individual does not satisfy linear constraints.") flag = False + if not self.check_linear_equality_constraints(x): 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): + 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 a9bc512e..164bdebb 100644 --- a/CADETProcess/optimization/optimizer.py +++ b/CADETProcess/optimization/optimizer.py @@ -410,7 +410,12 @@ def check_x0(self, optimization_problem, x0): 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, + check_nonlinear_constraints=False, + silent=True, + ): flag = False break diff --git a/CADETProcess/optimization/pymooAdapter.py b/CADETProcess/optimization/pymooAdapter.py index c126ce96..3fb48c25 100644 --- a/CADETProcess/optimization/pymooAdapter.py +++ b/CADETProcess/optimization/pymooAdapter.py @@ -272,7 +272,11 @@ 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, + check_nonlinear_constraints=False, + ): if X_new is None: X_new = self.optimization_problem.create_initial_values( len(X), include_dependent_variables=False From e4cc118700c06f4882137d2b4ff69d0911ced82a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 28 Jun 2024 18:26:42 +0200 Subject: [PATCH 55/92] Rename eps_eq -> eps_lineq --- CADETProcess/optimization/optimizationProblem.py | 8 ++++---- CADETProcess/optimization/scipyAdapter.py | 4 ++-- tests/test_optimization_problem.py | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index 94554ce8..f6039e28 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -2380,7 +2380,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) @@ -2539,7 +2539,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 @@ -2547,7 +2547,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 @@ -2595,7 +2595,7 @@ def check_linear_equality_constraints(self, x): flag = True lhs = self.evaluate_linear_equality_constraints(x) - if np.any(np.abs(lhs) > self.eps_eq): + if np.any(np.abs(lhs) > self.eps_lineq): flag = False return flag diff --git a/CADETProcess/optimization/scipyAdapter.py b/CADETProcess/optimization/scipyAdapter.py index 21cd157a..796bec3c 100644 --- a/CADETProcess/optimization/scipyAdapter.py +++ b/CADETProcess/optimization/scipyAdapter.py @@ -230,8 +230,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, diff --git a/tests/test_optimization_problem.py b/tests/test_optimization_problem.py index 32ed3571..c1c9d5d1 100644 --- a/tests/test_optimization_problem.py +++ b/tests/test_optimization_problem.py @@ -1142,7 +1142,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 From b3fbe27da1532b79da728ec10c7dac5933d20875 Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Tue, 6 Aug 2024 13:20:19 +0200 Subject: [PATCH 56/92] Make callback error logger warning more verbose. --- CADETProcess/optimization/optimizationProblem.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index f6039e28..895dc5b9 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -1635,11 +1635,10 @@ def evaluate_callbacks(ind): try: self._evaluate(ind.x_transformed, callback, force, untransform=True) - except CADETProcessError: + except CADETProcessError as e: self.logger.warning( - f'Evaluation of {callback} failed at {ind.x}.' + f'Evaluation of {callback} failed at {ind.x} with Error "{e}".' ) - parallelization_backend.evaluate(evaluate_callbacks, population) def evaluate_callbacks_population(self, *args, **kwargs): From 1d1037be297bcb56857325cd8cf2a7a53e094b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 31 Jul 2024 13:35:43 +0200 Subject: [PATCH 57/92] Update gets_dependent_variables decorators --- CADETProcess/optimization/axAdapater.py | 4 +++- .../optimization/optimizationProblem.py | 22 ++++++++++++------- CADETProcess/optimization/pymooAdapter.py | 3 +++ CADETProcess/optimization/scipyAdapter.py | 11 ++++++---- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/CADETProcess/optimization/axAdapater.py b/CADETProcess/optimization/axAdapater.py index bfc5360e..a39674aa 100644 --- a/CADETProcess/optimization/axAdapater.py +++ b/CADETProcess/optimization/axAdapater.py @@ -110,6 +110,7 @@ def run(self, trial: BaseTrial) -> Dict[str, Any]: F = obj_fun( X, untransform=True, + get_dependent_values=True, ensure_minimization=True, parallelization_backend=self.parallelization_backend ) @@ -123,6 +124,7 @@ def run(self, trial: BaseTrial) -> Dict[str, Any]: CV = nonlincon_cv_fun( X, untransform=True, + get_dependent_values=True, parallelization_backend=self.parallelization_backend ) @@ -314,7 +316,7 @@ def _post_processing(self, trial): G = G_data["mean"].values.reshape((op.n_nonlinear_constraints, n_ind)).T nonlincon_cv_fun = op.evaluate_nonlinear_constraints_violation - CV = nonlincon_cv_fun(X, untransform=True) + CV = nonlincon_cv_fun(X, untransform=True, get_dependent_values=True) else: G = None CV = None diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index 895dc5b9..5a3ccfcd 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -152,7 +152,7 @@ def wrapper(self, x, *args, get_dependent_values=False, **kwargs): return wrapper def ensures2d(func): - """Decorate function to ensure X array is an ndarray with ndmin=2.""" + """Ensure X array is an ndarray with ndmin=2.""" @wraps(func) def wrapper( self, @@ -654,6 +654,7 @@ def get_independent_values(self, X: npt.ArrayLike) -> np.ndarray: 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. @@ -685,9 +686,7 @@ 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_population( @@ -789,7 +788,6 @@ def _evaluate_individual( return results - @untransforms def _evaluate(self, x, func, force=False): """Iterate over all evaluation objects and evaluate at x. @@ -797,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 @@ -1070,6 +1069,7 @@ def add_objective( @ensures2d @untransforms + @gets_dependent_values @ensures_minimization(scores='objectives') def evaluate_objectives( self, @@ -1118,6 +1118,7 @@ def evaluate_objectives_population(self, *args, **kwargs): 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. @@ -1305,6 +1306,7 @@ def add_nonlinear_constraint( @ensures2d @untransforms + @gets_dependent_values def evaluate_nonlinear_constraints( self, X: npt.ArrayLike, @@ -1354,6 +1356,7 @@ def evaluate_nonlinear_constraints_population(self, *args, **kwargs): @ensures2d @untransforms + @gets_dependent_values def evaluate_nonlinear_constraints_violation( self, X: npt.ArrayLike, @@ -1416,6 +1419,7 @@ def evaluate_nonlinear_constraints_violation_population(self, *args, **kwargs): self.evaluate_nonlinear_constraints_violation(*args, *kwargs) @untransforms + @gets_dependent_values def check_nonlinear_constraints(self, x): """Check if all nonlinear constraints are met. @@ -1438,6 +1442,7 @@ def check_nonlinear_constraints(self, x): return True @untransforms + @gets_dependent_values def nonlinear_constraint_jacobian(self, x, dx=1e-3): """Compute jacobian of the nonlinear constraints at point x. @@ -1634,7 +1639,7 @@ def evaluate_callbacks(ind): callback._current_iteration = current_iteration try: - self._evaluate(ind.x_transformed, callback, force, untransform=True) + self._evaluate(ind.x, callback, force) except CADETProcessError as e: self.logger.warning( f'Evaluation of {callback} failed at {ind.x} with Error "{e}".' @@ -1760,6 +1765,7 @@ def add_meta_score( @ensures2d @untransforms + @gets_dependent_values @ensures_minimization(scores='meta_scores') def evaluate_meta_scores( self, @@ -2606,7 +2612,7 @@ def transform(self, X_independent: npt.ArrayLike) -> np.ndarray: Parameters ---------- x_independent : np.ndarray - Value of the independent optimization variables in untransformed space. + Independent optimization variables in untransformed space. Returns ------- @@ -2630,7 +2636,7 @@ def untransform(self, X_transformed: npt.ArrayLike) -> np.ndarray: Parameters ---------- X_transformed : npt.ArrayLike - Optimization variables in transformed parameter space. + Independent optimization variables in transformed parameter space. Returns ------- diff --git a/CADETProcess/optimization/pymooAdapter.py b/CADETProcess/optimization/pymooAdapter.py index 3fb48c25..8796def5 100644 --- a/CADETProcess/optimization/pymooAdapter.py +++ b/CADETProcess/optimization/pymooAdapter.py @@ -240,6 +240,7 @@ def _evaluate(self, X, out, *args, **kwargs): F = opt.evaluate_objectives( X, untransform=True, + get_dependent_values=True, ensure_minimization=True, parallelization_backend=self.parallelization_backend, ) @@ -249,11 +250,13 @@ def _evaluate(self, X, out, *args, **kwargs): G = opt.evaluate_nonlinear_constraints( X, untransform=True, + get_dependent_values=True, parallelization_backend=self.parallelization_backend, ) CV = opt.evaluate_nonlinear_constraints_violation( X, untransform=True, + get_dependent_values=True, parallelization_backend=self.parallelization_backend, ) out["G"] = np.array(CV) diff --git a/CADETProcess/optimization/scipyAdapter.py b/CADETProcess/optimization/scipyAdapter.py index 796bec3c..b74f9e61 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) @@ -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 From 3a8d88ef5c64151c001afde3927950eb93ff394c Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Wed, 31 Jul 2024 14:41:18 +0200 Subject: [PATCH 58/92] Rename decorator wrapper functions to improve stacktrace readability. --- CADETProcess/optimization/optimizationProblem.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index 5a3ccfcd..884b6544 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -131,30 +131,30 @@ 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): """Ensure X array is an ndarray with ndmin=2.""" @wraps(func) - def wrapper( + def wrapper_ensures2d( self, X: npt.ArrayLike, *args, **kwargs @@ -174,13 +174,13 @@ def wrapper( 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: @@ -188,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): From 815f4a4c95946734cf54941c1fe92194ed9e1abd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Tue, 6 Aug 2024 14:55:26 +0200 Subject: [PATCH 59/92] Add tolerance argument for constraint violation check methods --- .../optimization/optimizationProblem.py | 154 ++++++++++++++---- 1 file changed, 125 insertions(+), 29 deletions(-) diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index 884b6544..f7e7c7aa 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -5,7 +5,7 @@ from pathlib import Path import random import shutil -from typing import NoReturn, Any +from typing import Any, Optional, NoReturn import uuid import warnings @@ -1420,13 +1420,20 @@ def evaluate_nonlinear_constraints_violation_population(self, *args, **kwargs): @untransforms @gets_dependent_values - def check_nonlinear_constraints(self, x): + 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 ------- @@ -1434,10 +1441,23 @@ 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 @@ -2020,27 +2040,48 @@ def evaluate_bounds(self, x): @untransforms @gets_dependent_values - def check_bounds(self, x): + 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 : 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. If a scalar is provided, + the same value is used for all variables. Default is 0.0. Returns ------- - flag : Bool - True, if all values are within the bounds. False otherwise. + 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 @@ -2295,30 +2336,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 @@ -2583,24 +2646,45 @@ 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_lineq): + + 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 @@ -3190,41 +3274,53 @@ def check_config(self, ignore_linear_constraints=False): def check_individual( self, x: npt.ArrayLike, - check_nonlinear_constraints=False, + 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. + """ + 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. The default is True. - Note, depending on the nonlinear constraints, this can be an expensive - operations. + 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. + If True, suppress warnings. The default is False. Returns ------- bool 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." @@ -3232,7 +3328,7 @@ def check_individual( flag = False if check_nonlinear_constraints: - if not self.check_nonlinear_constraints(x): + if not self.check_nonlinear_constraints(x, cv_nonlincon_tol): flag = False if not silent: warnings.warn("Individual does not satisfy nonlinear constraints.") From 3b87fe8748b939d5af2a427f005cab0f74264840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Thu, 8 Aug 2024 16:48:19 +0200 Subject: [PATCH 60/92] Handle maximum number of initial evaluations in Ax --- CADETProcess/optimization/axAdapater.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CADETProcess/optimization/axAdapater.py b/CADETProcess/optimization/axAdapater.py index a39674aa..2328ef27 100644 --- a/CADETProcess/optimization/axAdapater.py +++ b/CADETProcess/optimization/axAdapater.py @@ -25,6 +25,7 @@ LogExpectedImprovement ) +from CADETProcess import CADETProcessError from CADETProcess.dataStructure import UnsignedInteger, Typed, Float from CADETProcess.optimization.optimizationProblem import OptimizationProblem from CADETProcess.optimization import OptimizerBase @@ -425,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. From 01153006edc2798295824b9a00f1d2c11cc0a0f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 28 Jun 2024 17:23:28 +0200 Subject: [PATCH 61/92] Distinguish between different constraint violations --- CADETProcess/optimization/axAdapater.py | 2 +- CADETProcess/optimization/individual.py | 110 +++++++++---- .../optimization/optimizationProblem.py | 94 ++++++----- CADETProcess/optimization/optimizer.py | 102 ++++++++---- CADETProcess/optimization/population.py | 150 ++++++++---------- CADETProcess/optimization/pymooAdapter.py | 16 +- CADETProcess/optimization/results.py | 43 +++-- CADETProcess/optimization/scipyAdapter.py | 10 +- tests/test_individual.py | 8 +- tests/test_optimizer_behavior.py | 16 +- tests/test_population.py | 18 +-- 11 files changed, 336 insertions(+), 233 deletions(-) diff --git a/CADETProcess/optimization/axAdapater.py b/CADETProcess/optimization/axAdapater.py index 2328ef27..e3005a10 100644 --- a/CADETProcess/optimization/axAdapater.py +++ b/CADETProcess/optimization/axAdapater.py @@ -331,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, ) diff --git a/CADETProcess/optimization/individual.py b/CADETProcess/optimization/individual.py index 17c5f1e4..01bee878 100644 --- a/CADETProcess/optimization/individual.py +++ b/CADETProcess/optimization/individual.py @@ -5,7 +5,7 @@ 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: np.ndarray) -> str: @@ -43,20 +43,26 @@ class Individual(Structure): 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 -------- @@ -65,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: @@ -120,14 +137,17 @@ 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) -> str: @@ -142,14 +162,6 @@ def is_evaluated(self) -> bool: else: 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) -> int: """int: Number of variables.""" @@ -178,6 +190,22 @@ def n_m(self) -> int: else: return len(self.m) + @property + 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).""" @@ -212,12 +240,17 @@ def dominates(self, other: "Individual") -> bool: 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 @@ -374,23 +407,34 @@ def to_dict(self) -> dict: 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 diff --git a/CADETProcess/optimization/optimizationProblem.py b/CADETProcess/optimization/optimizationProblem.py index f7e7c7aa..1ab24104 100644 --- a/CADETProcess/optimization/optimizationProblem.py +++ b/CADETProcess/optimization/optimizationProblem.py @@ -3035,12 +3035,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: """ @@ -3052,16 +3051,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. @@ -3073,13 +3070,27 @@ 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 @@ -3090,11 +3101,10 @@ def create_population( self, 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, - F_min: npt.ArrayLike | None = None, - CV: npt.ArrayLike | None = None, - cv_tol: float = 0., M_min: npt.ArrayLike | None = None, ) -> Population: """ @@ -3106,16 +3116,14 @@ def create_population( Variable values in untransformed space. F : npt.ArrayLike Objective values. + F_min : npt.ArrayLike + Minimized objective values. G : npt.ArrayLike Nonlinear constraint values. + CV_nonlincon : npt.ArrayLike + Nonlinear constraints violation. M : npt.ArrayLike Meta score values. - F_min : npt.ArrayLike - Minimized objective values. - CV : npt.ArrayLike - Nonlinear constraints violation. - cv_tol : float - Tolerance for constraints violation. M_min : npt.ArrayLike Minimized meta score values. @@ -3131,34 +3139,46 @@ def create_population( 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 diff --git a/CADETProcess/optimization/optimizer.py b/CADETProcess/optimization/optimizer.py index 164bdebb..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. @@ -89,7 +98,11 @@ class OptimizerBase(Structure): 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): @@ -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: @@ -413,6 +432,9 @@ def check_x0(self, optimization_problem, x0): 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, ): @@ -426,13 +448,13 @@ 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( @@ -441,29 +463,40 @@ def _create_population(self, X_transformed, F, F_min, G, CV): 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 @@ -532,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}' @@ -543,7 +576,7 @@ def run_post_processing( X_transformed, F_minimized, G, - CV, + CV_nonlincon, current_generation, X_opt_transformed=None ): @@ -563,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. @@ -572,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) @@ -599,7 +636,6 @@ def run_post_processing( else: self._current_cache_entries.append(x_key) - # Remove old meta front entries from cache that were replaced by better ones for x_key in self._current_cache_entries: x = np.frombuffer(x_key) diff --git a/CADETProcess/optimization/population.py b/CADETProcess/optimization/population.py index b46a3da6..9dc02c96 100644 --- a/CADETProcess/optimization/population.py +++ b/CADETProcess/optimization/population.py @@ -119,9 +119,9 @@ def objective_labels(self) -> list[str]: 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) -> list[str]: @@ -250,6 +250,21 @@ 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 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.""" @@ -290,7 +305,7 @@ def g(self) -> np.ndarray: @property 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 @@ -312,28 +327,28 @@ def g_avg(self) -> np.ndarray: 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) -> np.ndarray: @@ -825,70 +840,10 @@ def from_dict(cls, data): 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: Population): """Update the Pareto front with new population. @@ -915,24 +870,29 @@ def update_population(self, population: 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 @@ -951,11 +911,30 @@ def update_population(self, population: 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() @@ -964,11 +943,8 @@ def update_population(self, population: 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.""" @@ -1001,7 +977,6 @@ 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 return front @@ -1020,7 +995,6 @@ def from_dict(cls, data): ParetoFront created from data. """ front = cls( - cv_tol=data["cv_tol"], similarity_tol=data["similarity_tol"], id=data["id"] ) diff --git a/CADETProcess/optimization/pymooAdapter.py b/CADETProcess/optimization/pymooAdapter.py index 8796def5..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) @@ -266,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) @@ -278,6 +279,9 @@ def _do(self, problem, X, **kwargs): 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: diff --git a/CADETProcess/optimization/results.py b/CADETProcess/optimization/results.py index ef348b49..53a998c4 100644 --- a/CADETProcess/optimization/results.py +++ b/CADETProcess/optimization/results.py @@ -68,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 @@ -78,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: @@ -182,7 +184,7 @@ def update_pareto(self, pareto_new: Population | None = 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: @@ -228,6 +230,21 @@ def x_transformed(self) -> np.ndarray: """np.array: Optimal points in transformed space.""" return self.meta_front.x_transformed + @property + 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.""" @@ -239,9 +256,9 @@ def g(self) -> np.ndarray: 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.ndarray: @@ -304,28 +321,28 @@ def g_avg_history(self) -> np.ndarray: 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) -> np.ndarray: diff --git a/CADETProcess/optimization/scipyAdapter.py b/CADETProcess/optimization/scipyAdapter.py index b74f9e61..271c73e3 100644 --- a/CADETProcess/optimization/scipyAdapter.py +++ b/CADETProcess/optimization/scipyAdapter.py @@ -398,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 @@ -449,10 +449,10 @@ class COBYLA(SciPyInterface): disp = Bool(default=False) catol = UnsignedFloat(default=0.0002) - x_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'] 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_optimizer_behavior.py b/tests/test_optimizer_behavior.py index aa985b8c..acd54301 100644 --- a/tests/test_optimizer_behavior.py +++ b/tests/test_optimizer_behavior.py @@ -43,7 +43,11 @@ F_TOL = 0.001 X_TOL = 0.001 -CV_TOL = 0.0001 + +CV_BOUNDS_TOL = 1e-9 +CV_LINCON_TOL = 1e-6 +CV_LINEQCON_TOL = 1e-9 +CV_NONLINCON_TOL = 0.0001 EXCLUDE_COMBINATIONS = [ ( @@ -78,12 +82,12 @@ def set_non_default_parameters(optimizer, problem): class TrustConstr(TrustConstr): x_tol = X_TOL - cv_tol = CV_TOL + cv_nonlincon_tol = CV_NONLINCON_TOL class COBYLA(COBYLA): x_tol = X_TOL - cv_tol = CV_TOL + cv_nonlincon_tol = CV_NONLINCON_TOL class NelderMead(NelderMead): @@ -93,17 +97,19 @@ class NelderMead(NelderMead): class SLSQP(SLSQP): x_tol = X_TOL + cv_lincon_tol = CV_LINCON_TOL class U_NSGA3(U_NSGA3): f_tol = F_TOL x_tol = X_TOL - cv_tol = CV_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 @@ -111,6 +117,7 @@ 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 @@ -118,6 +125,7 @@ class NEHVI(NEHVI): class qNParEGO(qNParEGO): + cv_lincon_tol = CV_LINCON_TOL n_init_evals = 50 early_stopping_improvement_bar = 1e-4 early_stopping_improvement_window = 10 diff --git a/tests/test_population.py b/tests/test_population.py index 3e0afc57..277f252a 100644 --- a/tests/test_population.py +++ b/tests/test_population.py @@ -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) From 612099d1d1669e230c0fb5e121ea7ee4a0e5db8c Mon Sep 17 00:00:00 2001 From: "Lanzrath, Hannah" Date: Fri, 12 Jul 2024 21:19:26 +0200 Subject: [PATCH 62/92] Add MCTDiscretization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Johannes Schmölder Co-authored-by: daklauss --- CADETProcess/processModel/discretization.py | 30 ++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/CADETProcess/processModel/discretization.py b/CADETProcess/processModel/discretization.py index a050d471..60b92c56 100644 --- a/CADETProcess/processModel/discretization.py +++ b/CADETProcess/processModel/discretization.py @@ -26,7 +26,8 @@ class for all other classes in this module and defines some common parameters. 'LRMPDiscretizationFV', 'LRMPDiscretizationDG', 'GRMDiscretizationFV', 'GRMDiscretizationDG', 'WenoParameters', 'ConsistencySolverParameters', - 'DGMixin' + 'DGMixin', + 'MCTDiscretizationFV', ] @@ -571,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'] From c17b91c0c24df6ea3aa368438da71aeb75dc7191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 12 Jul 2024 21:19:40 +0200 Subject: [PATCH 63/92] Add MCTRecorder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Johannes Schmölder Co-authored-by: daklauss Co-authored-by: Lanzrath, Hannah --- CADETProcess/processModel/solutionRecorder.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 From 65591fa5473f46c4939fad251b7043c7869a7e96 Mon Sep 17 00:00:00 2001 From: "Lanzrath, Hannah" Date: Fri, 12 Jul 2024 21:20:51 +0200 Subject: [PATCH 64/92] Add ports to unitOperation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Johannes Schmölder Co-authored-by: daklauss --- CADETProcess/processModel/unitOperation.py | 173 +++++++++++++++++++-- tests/test_unit_operation.py | 82 +++++++++- 2 files changed, 239 insertions(+), 16 deletions(-) diff --git a/CADETProcess/processModel/unitOperation.py b/CADETProcess/processModel/unitOperation.py index e1563029..fd76db4b 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.""" @@ -714,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): @@ -1161,3 +1202,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/tests/test_unit_operation.py b/tests/test_unit_operation.py index 8dbfc986..7a04b1fe 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 @@ -27,6 +28,14 @@ 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): @@ -60,6 +69,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' @@ -204,6 +223,7 @@ def test_parameters(self): 'q': [], 'V': volume, } + np.testing.assert_equal(parameters_expected, cstr.parameters) sec_dep_parameters_expected = { @@ -224,5 +244,65 @@ def test_parameters(self): self.assertEqual(cstr.required_parameters, ['V']) + 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__': unittest.main() From c5c06c762e84ae1d3b44b24ee9e4463fa5b97552 Mon Sep 17 00:00:00 2001 From: "Lanzrath, Hannah" Date: Fri, 12 Jul 2024 21:21:06 +0200 Subject: [PATCH 65/92] Add ports to flowSheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: daklauss Co-authored-by: Johannes Schmölder --- CADETProcess/processModel/flowSheet.py | 441 ++++++-- tests/test_flow_sheet.py | 1321 ++++++++++++++++++++---- 2 files changed, 1481 insertions(+), 281 deletions(-) diff --git a/CADETProcess/processModel/flowSheet.py b/CADETProcess/processModel/flowSheet.py index fa5985a3..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 ------ @@ -352,17 +408,43 @@ def add_connection(self, origin, destination): if isinstance(destination, Inlet): raise CADETProcessError("Inlet unit cannot have ingoing stream.") - if destination in self.connections[origin].destinations: - raise CADETProcessError('Connection already exists') - self._connections[origin].destinations.append(destination) - self._connections[destination].origins.append(origin) + 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 not destination.has_ports: + destination_port = destination.ports[0] + if not origin.has_ports: + origin_port = origin.ports[0] + + 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 @@ -371,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 ------ @@ -386,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 @@ -407,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 @@ -432,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: @@ -450,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.") @@ -487,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 @@ -500,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 ------ @@ -508,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 = [] @@ -529,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.") @@ -548,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 @@ -590,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 " @@ -634,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 @@ -643,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: @@ -656,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']: + + port_index = port_index_list.index((unit, port)) - 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] + 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 @@ -871,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/tests/test_flow_sheet.py b/tests/test_flow_sheet.py index 74d009de..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 @@ -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,7 +945,9 @@ def test_output_state(self): column, { 'column': 0.1, - 'outlet': 0.9, + 'outlet': { + 0: 0.9, + } } ) @@ -581,6 +980,9 @@ def test_add_connection_error(self): 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): """ @@ -619,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) @@ -632,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) @@ -645,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) @@ -658,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) @@ -672,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) @@ -689,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""" @@ -740,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) @@ -853,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) @@ -933,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 From 5ae2b84d2c60ea223b3387e3ecd3ce51039675ef Mon Sep 17 00:00:00 2001 From: "Lanzrath, Hannah" Date: Fri, 12 Jul 2024 21:19:55 +0200 Subject: [PATCH 66/92] Add ports to process MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: daklauss Co-authored-by: Johannes Schmölder --- CADETProcess/processModel/process.py | 134 ++++++++++++++++----------- 1 file changed, 80 insertions(+), 54 deletions(-) diff --git a/CADETProcess/processModel/process.py b/CADETProcess/processModel/process.py index 04db23dc..78fec48d 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) @@ -685,11 +709,13 @@ def check_cstr_volume(self): 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.') + 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 From 29404d1aa273aec11413463a1a20299787519a42 Mon Sep 17 00:00:00 2001 From: daklauss Date: Fri, 12 Jul 2024 21:20:11 +0200 Subject: [PATCH 67/92] Add ports to carouselBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Johannes Schmölder Co-authored-by: Lanzrath, Hannah --- CADETProcess/modelBuilder/carouselBuilder.py | 10 +++---- tests/test_carousel.py | 28 ++++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/CADETProcess/modelBuilder/carouselBuilder.py b/CADETProcess/modelBuilder/carouselBuilder.py index a4d677db..1ad176fe 100644 --- a/CADETProcess/modelBuilder/carouselBuilder.py +++ b/CADETProcess/modelBuilder/carouselBuilder.py @@ -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/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) From f7774f10283e75b17ad96425fd817670291421df Mon Sep 17 00:00:00 2001 From: "Lanzrath, Hannah" Date: Fri, 12 Jul 2024 21:20:25 +0200 Subject: [PATCH 68/92] Add ports to compartmentBuilder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: daklauss Co-authored-by: Johannes Schmölder --- .../modelBuilder/compartmentBuilder.py | 21 ++- tests/test_compartment.py | 140 ++++++++++++++---- 2 files changed, 127 insertions(+), 34 deletions(-) diff --git a/CADETProcess/modelBuilder/compartmentBuilder.py b/CADETProcess/modelBuilder/compartmentBuilder.py index 9a99dde8..3a488e28 100644 --- a/CADETProcess/modelBuilder/compartmentBuilder.py +++ b/CADETProcess/modelBuilder/compartmentBuilder.py @@ -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/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() From 9f1e689b2aba1063a34ffa3bc42dbc5f3a1720ea Mon Sep 17 00:00:00 2001 From: daklauss Date: Fri, 12 Jul 2024 21:28:18 +0200 Subject: [PATCH 69/92] Add ports to simulationResults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Johannes Schmölder Co-authored-by: daklauss Co-authored-by: Lanzrath, Hannah --- CADETProcess/simulationResults.py | 77 ++++++++++++++++++++++--------- 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/CADETProcess/simulationResults.py b/CADETProcess/simulationResults.py index 3088be13..96d82377 100644 --- a/CADETProcess/simulationResults.py +++ b/CADETProcess/simulationResults.py @@ -142,16 +142,31 @@ 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 + for i in range(1, self.n_cycles): + solution_complete = np.vstack(( + 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 + 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() self._solution = solution @@ -166,18 +181,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 From 546830c2f675ffe116698d677dd9b786d04769fa Mon Sep 17 00:00:00 2001 From: daklauss Date: Fri, 12 Jul 2024 21:19:07 +0200 Subject: [PATCH 70/92] Add ports to cadetAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Johannes Schmölder Co-authored-by: Lanzrath, Hannah --- CADETProcess/simulator/cadetAdapter.py | 371 ++++++++++++++++--------- 1 file changed, 240 insertions(+), 131 deletions(-) diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index 3af7ec3a..69da6ad8 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -552,74 +552,105 @@ def get_simulation_results( 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: - start = 0 - for cycle in range(self.n_cycles): - end = start + 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 - ) - ) + flow_in = process.flow_rate_timelines[unit.name].total_in[port] + flow_out = process.flow_rate_timelines[unit.name].total_out[port] - 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 - ) - ) + start = 0 + for cycle in range(self.n_cycles): + 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 '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 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 '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_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_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, :] @@ -638,8 +669,17 @@ def get_simulation_results( 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 = \ @@ -647,77 +687,102 @@ def get_simulation_results( 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 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 + 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_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 '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_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_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_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_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 + ) ) - ) - - 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) @@ -786,6 +851,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 @@ -828,21 +895,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(): @@ -869,6 +946,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. @@ -1328,6 +1423,19 @@ class ModelSolverParameters(Structure): 'FLOWRATE_FILTER': 'flow_rate_filter', }, }, + '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': { 'name': 'INLET', 'parameters': { @@ -1881,11 +1989,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', ] From c0babf216edd05f07e5dd2cd363cc9699564a302 Mon Sep 17 00:00:00 2001 From: daklauss Date: Thu, 1 Aug 2024 14:01:25 +0200 Subject: [PATCH 71/92] Add singelton dimension handling to solution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Lanzrath, Hannah Co-authored-by: Johannes Schmölder --- CADETProcess/solution.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/CADETProcess/solution.py b/CADETProcess/solution.py index 1ec57122..77dffa44 100755 --- a/CADETProcess/solution.py +++ b/CADETProcess/solution.py @@ -786,10 +786,11 @@ def __init__( self.component_system_original = component_system self.time_original = time + if axial_coordinates is not None and len(axial_coordinates) == 1: + axial_coordinates = None + 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 @@ -1058,14 +1059,21 @@ def __init__( particle_coordinates=None ): + if axial_coordinates is not None and len(axial_coordinates) == 1: + axial_coordinates = None + 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) @@ -1146,7 +1154,6 @@ def _plot_1D( return ax - def _plot_2D(self, t, comp, vmax, ax=None): x = self.axial_coordinates y = self.particle_coordinates @@ -1228,6 +1235,8 @@ def __init__( self.bound_states = bound_states + if axial_coordinates is not None and len(axial_coordinates) == 1: + axial_coordinates = None 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: @@ -1679,9 +1688,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] From 4e8dad53dc4678c3d921e0f645ac12adcbb2189f Mon Sep 17 00:00:00 2001 From: "Lanzrath, Hannah" Date: Thu, 1 Aug 2024 13:54:56 +0200 Subject: [PATCH 72/92] Add method to get information about CADET version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Johannes Schmölder --- CADETProcess/simulator/cadetAdapter.py | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index 69da6ad8..b114135f 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -9,6 +9,7 @@ import time import tempfile import warnings +import re from addict import Dict import numpy as np @@ -447,6 +448,45 @@ 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.cadet_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 From e1e624f781f984b2024ca03253d16ce313923b56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 14 Aug 2024 12:51:56 +0200 Subject: [PATCH 73/92] Formatting --- CADETProcess/simulator/cadetAdapter.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index b114135f..c6105d9c 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 @@ -1333,7 +1334,6 @@ def __str__(self): return 'CADET' -from CADETProcess.dataStructure import Structure, ParameterWrapper class ModelSolverParameters(Structure): """Converter for model solver parameters from CADETProcess to CADET. @@ -1447,7 +1447,7 @@ class ModelSolverParameters(Structure): 'COL_LENGTH': 'length', 'CROSS_SECTION_AREA': 'cross_section_area', 'VELOCITY': 'flow_direction', - }, + }, 'fixed': { 'TOTAL_POROSITY': 1, }, @@ -1474,7 +1474,7 @@ class ModelSolverParameters(Structure): 'CHANNEL_CROSS_SECTION_AREAS': 'channel_cross_section_areas', 'EXCHANGE_MATRIX': 'exchange_matrix', 'VELOCITY': 'flow_direction', - }, + }, }, 'Inlet': { 'name': 'INLET', @@ -1849,7 +1849,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', From 5fed4a8bfdd2f8f9642f6f8aecdb31bda162acc9 Mon Sep 17 00:00:00 2001 From: "Lanzrath, Hannah" Date: Thu, 1 Aug 2024 14:08:06 +0200 Subject: [PATCH 74/92] Update tests to pytest Use relative imports in tests, to make it compatible with pytest. --- tests/test_cadet_reactions.py | 2 +- tests/test_optimization_problem.py | 4 ++-- tests/test_optimization_results.py | 4 ++-- tests/test_parallelization_adapter.py | 4 ++-- tests/test_population.py | 2 +- tests/test_pymoo.py | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_cadet_reactions.py b/tests/test_cadet_reactions.py index 241016b1..8ae00175 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): diff --git a/tests/test_optimization_problem.py b/tests/test_optimization_problem.py index c1c9d5d1..ee471728 100644 --- a/tests/test_optimization_problem.py +++ b/tests/test_optimization_problem.py @@ -11,7 +11,7 @@ Structure, Float, List, SizedList, SizedNdArray, Polynomial, NdPolynomial ) from CADETProcess.optimization import OptimizationProblem -from optimization_problem_fixtures import ( +from tests.optimization_problem_fixtures import ( LinearConstraintsSooTestProblem2, LinearEqualityConstraintsSooTestProblem ) @@ -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'): 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_parallelization_adapter.py b/tests/test_parallelization_adapter.py index 9d441451..ba8c91b6 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( diff --git a/tests/test_population.py b/tests/test_population.py index 277f252a..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 diff --git a/tests/test_pymoo.py b/tests/test_pymoo.py index be0a5210..a27a08cf 100644 --- a/tests/test_pymoo.py +++ b/tests/test_pymoo.py @@ -2,7 +2,7 @@ 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): From 29b5066fd5f015399a5222fbfe689511b3cca1e0 Mon Sep 17 00:00:00 2001 From: "Lanzrath, Hannah" Date: Thu, 1 Aug 2024 14:17:47 +0200 Subject: [PATCH 75/92] Add create_LWE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds create_LWE, a collection of functions to quickly and semi-modularly set up a load-wash-elude process with CADET-Process Co-authored-by: Johannes Schmölder --- tests/create_LWE.py | 414 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 tests/create_LWE.py diff --git a/tests/create_LWE.py b/tests/create_LWE.py new file mode 100644 index 00000000..4a875cbd --- /dev/null +++ b/tests/create_LWE.py @@ -0,0 +1,414 @@ +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') + cstr.V = 1e-3 + cstr.porosity = 0.37 + (1.0 - 0.37) * 0.75 + + 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, **kwargs) + 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, **kwargs) + 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 From 0794a23a45878b19e311f0a1984566f94930080e Mon Sep 17 00:00:00 2001 From: "Lanzrath, Hannah" Date: Thu, 1 Aug 2024 14:20:09 +0200 Subject: [PATCH 76/92] Add LWE tests to test_cadet_adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests for the LWE process and simulation results Co-authored-by: Johannes Schmölder --- tests/test_cadet_adapter.py | 604 +++++++++++++++++++++++++++++++++++- 1 file changed, 603 insertions(+), 1 deletion(-) diff --git a/tests/test_cadet_adapter.py b/tests/test_cadet_adapter.py index b1ac69b1..17559e9b 100644 --- a/tests/test_cadet_adapter.py +++ b/tests/test_cadet_adapter.py @@ -2,8 +2,19 @@ import platform import shutil import unittest +import warnings +from typing import Optional +import pytest +import numpy as np +import numpy.testing as npt +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(): @@ -70,5 +81,596 @@ def tearDown(self): shutil.rmtree('./tmp', ignore_errors=True) -if __name__ == '__main__': +# TODO: Update when MCT is included in CADET-Python +# Include path to latest CADET-Core version here to test the MCT +cadet_install_path = None +process_simulator = Cadet(install_path=cadet_install_path) +cadet_version, branch_name = process_simulator.get_cadet_version() + +if cadet_version < '5.0.0' and branch_name == 'GITDIR-NOTFOUND branch': + unit_types = [ + 'Cstr', 'GeneralRateModel', 'TubularReactor', + 'LumpedRateModelWithoutPores', 'LumpedRateModelWithPores' + ] +else: + unit_types = [ + 'Cstr', 'GeneralRateModel', 'TubularReactor', + 'LumpedRateModelWithoutPores', 'LumpedRateModelWithPores', 'MCT' + ] + + +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. + cadet_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) + + cadet_version, branch_name = process_simulator.get_cadet_version() + if cadet_version < '5.0.0' and branch_name == 'GITDIR-NOTFOUND branch': + warnings.warn( + f"Your current CADET-Core version ({cadet_version}) does not " + "support all unit operations of this CADET-Process version. " + "Please update to the latest CADET-Core version from " + "https://github.com/cadet/CADET-Core for full compatibility.", + UserWarning + ) + + 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(scope="class", params=unit_types) +def process(request: pytest.FixtureRequest): + """ + Fixture to set up the process for each unit type without running the simulation. + """ + unit_type = request.param + process = create_lwe(unit_type) + return process + + +@pytest.fixture(scope="class", params=unit_types) +def simulation_results(request: pytest.FixtureRequest): + """ + Fixture to set up the simulation for each unit type. + """ + unit_type = request.param + process = create_lwe(unit_type) + simulation_results = run_simulation(process, install_path=cadet_install_path) + return simulation_results + + +@pytest.mark.parametrize("process", unit_types, 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=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_VOLUME == 0.001 + assert unit_config.POROSITY == 0.8425 + 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", unit_types, 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 particle discretization + else: + # assert solution particle has shape (t, n_col, n_par, 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__": unittest.main() From 36adbeb97e3b5b640cfabb87b892c5cf09bf9740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 2 Oct 2024 16:15:33 +0200 Subject: [PATCH 77/92] Fix default value for start time when creating fractions --- CADETProcess/solution.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CADETProcess/solution.py b/CADETProcess/solution.py index 77dffa44..9c525e06 100755 --- a/CADETProcess/solution.py +++ b/CADETProcess/solution.py @@ -446,6 +446,9 @@ 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 @@ -468,6 +471,12 @@ def create_fraction(self, start=None, end=None): 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) @@ -490,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 @@ -529,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 From 1458a0b5a6454140a45754349a4b920b2b83da61 Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Thu, 26 Sep 2024 18:44:48 +0200 Subject: [PATCH 78/92] Fix loading of multi-cycle solutions --- CADETProcess/simulator/cadetAdapter.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index c6105d9c..1ea0b803 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -693,17 +693,17 @@ def get_simulation_results( ) ) - 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 + 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 + ) ) - ) - start = end - 1 + start = end - 1 solution = Dict(solution) From 336ae3572304b6f4f81ddf95c41da3fc26caf934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Thu, 14 Nov 2024 11:12:53 +0100 Subject: [PATCH 79/92] Pin ax version The `FixedNoiseGP` was deprecated in Botorch 0.12.0 / ax 0.4.3 Until this is fixed in CADET-Process, we pin ax to <0.4.3 See also: https://github.com/fau-advanced-separations/CADET-Process/issues/174 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7797ba86..1bd6c1ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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,<0.4.3" ] docs = [ "myst-nb>=0.17.1", @@ -56,7 +56,7 @@ docs = [ ] ax = [ - "ax-platform>=0.3.5", + "ax-platform >=0.3.5,<0.4.3" ] [project.urls] From e1be7cb56168fdd92a4027023e28a9acfb31d266 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Thu, 14 Nov 2024 10:57:06 +0100 Subject: [PATCH 80/92] Temporarily disable Windows tests to avoid crashes Currently, a bug in setup-miniconda leads to conda not being activated which makes all tests fail. For more information, refer to: https://github.com/conda-incubator/setup-miniconda/issues/371 --- .github/workflows/pipeline.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 6f23fe8d..20cd68ff 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -21,8 +21,8 @@ jobs: os: [ubuntu-latest] python-version: ["3.10", "3.11", "3.12"] include: - - os: windows-latest - python-version: "3.12" + # - os: windows-latest + # python-version: "3.12" - os: macos-12 python-version: "3.12" From 66d6b9de5795c40443e5415abb4db5a02c2bf613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Thu, 14 Nov 2024 13:04:54 +0100 Subject: [PATCH 81/92] Pin CADET-Python version Limit CADET-Python version to <1.0.0 until #169 is merged. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 1bd6c1ea..e1c4c35a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "addict==2.3", - "cadet-python>=0.14", + "cadet-python>=0.14,<1.0.0", "corner>=2.2.1", "diskcache>=5.4.0", "hopsy>=1.4.0", From 35ba3115802c022f7759e497fdd77b61d0b0533a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Thu, 14 Nov 2024 15:37:06 +0100 Subject: [PATCH 82/92] Fix AxInterface options A typo in the paramameters list lead to an issue when querying the optimizer options. --- CADETProcess/optimization/axAdapater.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CADETProcess/optimization/axAdapater.py b/CADETProcess/optimization/axAdapater.py index e3005a10..a3a2c782 100644 --- a/CADETProcess/optimization/axAdapater.py +++ b/CADETProcess/optimization/axAdapater.py @@ -178,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', From 20ead9c1dfa0c54e8aeca045e7248f93347bb967 Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Sat, 17 Aug 2024 01:39:30 +0200 Subject: [PATCH 83/92] Adapt cadetAdapter.py to match new install_path settings in CADET-Python. Fix Cadet.get_cadet_version() Fix create_lwe method of CadetAdapter --- CADETProcess/simulator/cadetAdapter.py | 228 +++---------------------- tests/test_cadet_adapter.py | 19 ++- 2 files changed, 29 insertions(+), 218 deletions(-) diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index 1ea0b803..636df4a1 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -94,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 @@ -144,143 +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() - def check_cadet(self): """ Check if CADET installation can run a basic LWE example. @@ -304,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): @@ -412,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: @@ -429,13 +238,13 @@ def run(self, process, cadet=None, file_path=None): if 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: @@ -467,7 +276,7 @@ def get_cadet_version(self) -> tuple[str, str]: """ try: result = subprocess.run( - [self.cadet_path, '--version'], + [self.get_new_cadet_instance().cadet_cli_path, '--version'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -489,10 +298,7 @@ def get_cadet_version(self) -> tuple[str, str]: 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. - 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): @@ -516,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. @@ -568,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 @@ -587,8 +397,8 @@ 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() diff --git a/tests/test_cadet_adapter.py b/tests/test_cadet_adapter.py index 17559e9b..b234653f 100644 --- a/tests/test_cadet_adapter.py +++ b/tests/test_cadet_adapter.py @@ -42,20 +42,19 @@ def __init__(self, methodName='runTest'): @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) + self.assertEqual(cli_path, simulator.get_new_cadet_instance().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 + self.assertEqual(cli_path, simulator.get_new_cadet_instance().cadet_cli_path) simulator.install_path = cli_path.parent.parent - self.assertEqual(cli_path, simulator.cadet_cli_path) + self.assertEqual(cli_path, simulator.get_new_cadet_instance().cadet_cli_path) simulator = Cadet(install_path) - self.assertEqual(cli_path, simulator.cadet_cli_path) + self.assertEqual(cli_path, simulator.get_new_cadet_instance().cadet_cli_path) with self.assertRaises(FileNotFoundError): - simulator = Cadet('foo/bar') + simulator = Cadet('foo/bar').get_new_cadet_instance() @unittest.skipIf(found_cadet is False, "Skip if CADET is not installed.") def test_check_cadet(self): @@ -65,7 +64,8 @@ def test_check_cadet(self): 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.") @@ -74,7 +74,8 @@ def test_create_lwe(self): 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): From bdc937dd174bfd88e585cf82a40506ffc56f6f93 Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Thu, 14 Nov 2024 16:53:30 +0100 Subject: [PATCH 84/92] Raise required CADET-Python version to v1.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e1c4c35a..74d13c4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ ] dependencies = [ "addict==2.3", - "cadet-python>=0.14,<1.0.0", + "cadet-python>=1.0", "corner>=2.2.1", "diskcache>=5.4.0", "hopsy>=1.4.0", From 4d9d3d5fa6a1b277e33b64b69c702dcce87925a9 Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Fri, 15 Nov 2024 10:03:08 +0100 Subject: [PATCH 85/92] Adapt to new CSTR interface. --- CADETProcess/equilibria/initial_conditions.py | 4 +- CADETProcess/modelBuilder/carouselBuilder.py | 4 +- .../modelBuilder/compartmentBuilder.py | 2 +- CADETProcess/processModel/process.py | 2 +- CADETProcess/processModel/unitOperation.py | 50 ++++++++++++++++--- CADETProcess/simulator/cadetAdapter.py | 4 +- tests/create_LWE.py | 7 ++- tests/test_cadet_reactions.py | 6 ++- tests/test_equilibrium.py | 2 +- tests/test_unit_operation.py | 12 +++-- 10 files changed, 68 insertions(+), 25 deletions(-) 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/modelBuilder/carouselBuilder.py b/CADETProcess/modelBuilder/carouselBuilder.py index 1ad176fe..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) diff --git a/CADETProcess/modelBuilder/compartmentBuilder.py b/CADETProcess/modelBuilder/compartmentBuilder.py index 3a488e28..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) diff --git a/CADETProcess/processModel/process.py b/CADETProcess/processModel/process.py index 78fec48d..04ddbf91 100644 --- a/CADETProcess/processModel/process.py +++ b/CADETProcess/processModel/process.py @@ -708,7 +708,7 @@ def check_cstr_volume(self): for cstr in self.flow_sheet.cstrs: if cstr.flow_rate is None: continue - V_0 = cstr.V + 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() diff --git a/CADETProcess/processModel/unitOperation.py b/CADETProcess/processModel/unitOperation.py index fd76db4b..04d8bfed 100644 --- a/CADETProcess/processModel/unitOperation.py +++ b/CADETProcess/processModel/unitOperation.py @@ -1111,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 @@ -1126,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 + \ @@ -1137,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): @@ -1156,20 +1161,51 @@ 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 calculate_interstitial_rt(self, flow_rate): """Calculate mean residence time of a (non adsorbing) volume element. diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index 636df4a1..a3ec0c2e 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -1266,11 +1266,11 @@ 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': { diff --git a/tests/create_LWE.py b/tests/create_LWE.py index 4a875cbd..3280f893 100644 --- a/tests/create_LWE.py +++ b/tests/create_LWE.py @@ -118,8 +118,11 @@ def configure_cstr(component_system: ComponentSystem, **kwargs) -> Cstr: The configured CSTR. """ cstr = Cstr(component_system, name='Cstr') - cstr.V = 1e-3 - cstr.porosity = 0.37 + (1.0 - 0.37) * 0.75 + + 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) diff --git a/tests/test_cadet_reactions.py b/tests/test_cadet_reactions.py index 8ae00175..380cc5f6 100644 --- a/tests/test_cadet_reactions.py +++ b/tests/test_cadet_reactions.py @@ -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_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_unit_operation.py b/tests/test_unit_operation.py index 7a04b1fe..936135b6 100644 --- a/tests/test_unit_operation.py +++ b/tests/test_unit_operation.py @@ -25,6 +25,8 @@ 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 @@ -53,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 @@ -217,11 +219,11 @@ 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) @@ -241,7 +243,7 @@ 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): From 8b4dc2711ab04118101007276c030399bfd2c243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Wed, 2 Oct 2024 16:38:05 +0200 Subject: [PATCH 86/92] Deprecate Mambaforge With the Miniforge 23.3.1 release, the Miniforge and Mambaforge installers became essentially identical. The only difference between the two was their name and, subsequently, the default installation directory. For more information, see https://conda-forge.org/news/2024/07/29/sunsetting-mambaforge/ --- .github/workflows/pipeline.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 20cd68ff..257a3275 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -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 From c2383f81bc1d84bd945448b1285e92f9c4ad150e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Fri, 15 Nov 2024 13:59:23 +0100 Subject: [PATCH 87/92] Improve cleanup after tests --- tests/test_optimization_integration.py | 19 +++++++++++++++++++ tests/test_optimization_problem.py | 4 ++++ tests/test_pymoo.py | 6 ++++++ 3 files changed, 29 insertions(+) diff --git a/tests/test_optimization_integration.py b/tests/test_optimization_integration.py index c721cd0a..59750944 100644 --- a/tests/test_optimization_integration.py +++ b/tests/test_optimization_integration.py @@ -19,6 +19,13 @@ 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' @@ -35,6 +42,8 @@ def tearDown(self): shutil.rmtree('./diskcache_batch_elution_single', ignore_errors=True) shutil.rmtree('./tmp', ignore_errors=True) + settings.working_directory = None + def test_single_core(self): if not test_batch_elution_single_objective_single_core: self.skipTest("Skipping test_batch_elution_single_objective_single_core") @@ -80,6 +89,9 @@ 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' @@ -96,6 +108,8 @@ def tearDown(self): shutil.rmtree('./diskcache_batch_elution_multi', ignore_errors=True) shutil.rmtree('./tmp', ignore_errors=True) + settings.working_directory = None + @unittest.skipIf(__name__ != "__main__", "Only run test if test is run as __main__") def test_optimization(self): if not test_batch_elution_multi_objective: @@ -125,6 +139,9 @@ 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' @@ -146,6 +163,8 @@ def tearDown(self): 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") diff --git a/tests/test_optimization_problem.py b/tests/test_optimization_problem.py index ee471728..452d45d5 100644 --- a/tests/test_optimization_problem.py +++ b/tests/test_optimization_problem.py @@ -680,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 diff --git a/tests/test_pymoo.py b/tests/test_pymoo.py index a27a08cf..5c2f9fbd 100644 --- a/tests/test_pymoo.py +++ b/tests/test_pymoo.py @@ -1,5 +1,7 @@ import unittest +import shutil +from CADETProcess import settings from CADETProcess.optimization import U_NSGA3 from tests.test_optimization_problem import setup_optimization_problem @@ -9,6 +11,10 @@ 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): From c5b4842159081e6122a888c25e42874f34c2bb34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johannes=20Schm=C3=B6lder?= Date: Mon, 22 Jul 2024 12:44:27 +0200 Subject: [PATCH 88/92] Remove `setup.cfg` Forgot to remove this file when migrating to `pyproject.toml`. --- pyproject.toml | 2 ++ setup.cfg | 65 -------------------------------------------------- 2 files changed, 2 insertions(+), 65 deletions(-) delete mode 100644 setup.cfg diff --git a/pyproject.toml b/pyproject.toml index 74d13c4d..d68809e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = ["CADETProcess"] [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 From 12d7defb3097f80ccab6effceed591e971c4e37f Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Fri, 2 Aug 2024 17:16:46 +0200 Subject: [PATCH 89/92] For mobile phase modulator binding add linear threshold parameter. --- CADETProcess/processModel/binding.py | 4 ++++ CADETProcess/simulator/cadetAdapter.py | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CADETProcess/processModel/binding.py b/CADETProcess/processModel/binding.py index 18379441..21b39fc3 100644 --- a/CADETProcess/processModel/binding.py +++ b/CADETProcess/processModel/binding.py @@ -518,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') @@ -527,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', @@ -534,6 +537,7 @@ class MobilePhaseModulator(BindingBaseClass): 'capacity', 'ion_exchange_characteristic', 'hydrophobicity', + 'linear_threshold', ] diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index a3ec0c2e..5d9799dd 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -1453,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': { From ccdf509ce1313a1a241bc5bf1aa31a4515a1eb5b Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Thu, 14 Nov 2024 16:50:38 +0100 Subject: [PATCH 90/92] Add test for mobile phase modulator binding model. --- tests/test_binding.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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) From 8384f14e2b49529cc92c47909a9e18b4bea8085d Mon Sep 17 00:00:00 2001 From: Florian <33749653+flo-schu@users.noreply.github.com> Date: Tue, 19 Nov 2024 16:07:47 +0100 Subject: [PATCH 91/92] Use SingleTaskGP instead of FixedNoiseGP as recommended in the Deprecation Warning Co-authored-by: r.jaepel --- CADETProcess/optimization/axAdapater.py | 8 ++++---- pyproject.toml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CADETProcess/optimization/axAdapater.py b/CADETProcess/optimization/axAdapater.py index a3a2c782..3c9abb9e 100644 --- a/CADETProcess/optimization/axAdapater.py +++ b/CADETProcess/optimization/axAdapater.py @@ -20,7 +20,7 @@ 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 ) @@ -521,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' @@ -552,7 +552,7 @@ class NEHVI(MultiObjectiveAxInterface): supports_single_objective = False def __repr__(self): - smn = 'FixedNoiseGP' + smn = 'SingleTaskGP' afn = 'NEHVI' return f'{smn}+{afn}' @@ -578,7 +578,7 @@ class qNParEGO(MultiObjectiveAxInterface): supports_single_objective = False def __repr__(self): - smn = 'FixedNoiseGP' + smn = 'SingleTaskGP' afn = 'qNParEGO' return f'{smn}+{afn}' diff --git a/pyproject.toml b/pyproject.toml index d68809e6..8704b608 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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,<0.4.3" + "ax-platform >=0.3.5" ] docs = [ "myst-nb>=0.17.1", @@ -56,7 +56,7 @@ docs = [ ] ax = [ - "ax-platform >=0.3.5,<0.4.3" + "ax-platform >=0.3.5" ] [project.urls] From f641e7db72013bae567f933ac8504fcd1a1ed70e Mon Sep 17 00:00:00 2001 From: "r.jaepel" Date: Wed, 20 Nov 2024 22:34:27 +0100 Subject: [PATCH 92/92] Add docker_container property to CADETAdapter --- CADETProcess/simulator/cadetAdapter.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/CADETProcess/simulator/cadetAdapter.py b/CADETProcess/simulator/cadetAdapter.py index 5d9799dd..f9ab2cd8 100644 --- a/CADETProcess/simulator/cadetAdapter.py +++ b/CADETProcess/simulator/cadetAdapter.py @@ -91,7 +91,7 @@ class Cadet(SimulatorBase): use_dll = Bool(default=False) _force_constant_flow_rate = False - def __init__(self, install_path=None, temp_dir=None, *args, **kwargs): + def __init__(self, install_path=None, temp_dir=None, docker_container=None, *args, **kwargs): super().__init__(*args, **kwargs) if install_path is None: @@ -99,6 +99,8 @@ def __init__(self, install_path=None, temp_dir=None, *args, **kwargs): else: self.install_path = install_path + self.docker_container = docker_container + self.model_solver_parameters = ModelSolverParameters() self.solver_parameters = SolverParameters() self.time_integrator_parameters = SolverTimeIntegratorParameters() @@ -298,7 +300,7 @@ def get_cadet_version(self) -> tuple[str, str]: raise RuntimeError(f"Command execution failed: {e}") def get_new_cadet_instance(self): - cadet = CadetAPI(install_path=self.install_path, use_dll=self.use_dll) + cadet = CadetAPI(install_path=self.install_path, use_dll=self.use_dll, docker_container=self.docker_container) return cadet def save_to_h5(self, process, file_path):