Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Two-dimensional Transient Advection-Diffusion

This example looks at a control problem for a set of two-species advection-diffusion-reaction dynamics in a two-dimensional urban canyon. This example is in the article Hart et al., 2026. The physical situation is a contaminant (u1u_1) which will be contained by deploying a neutralizing retardant (u2u_2).

Dynamics

Denoting the spatial domain as Ω\Omega, the dynamics are the following:

u1t=κ1Δu1α2vu1ρ1u1u2,u2t=κ2Δu2α2vu2ρ2u1u2+f,nu1=nu2=0   for   xΩ,u1(x,0)=u0(x),u2(x,0)=0,(1)\tag{1} \begin{aligned} \frac{\partial u_1}{\partial t} &= \kappa_1\Delta u_1 - \alpha_2 \mathbf{v}\cdot \nabla u_1 - \rho_1 u_1 u_2, \\ \frac{\partial u_2}{\partial t} &= \kappa_2\Delta u_2 - \alpha_2 \mathbf{v}\cdot \nabla u_2 - \rho_2 u_1 u_2 + f, \\ \vec{n}\cdot\nabla u_1 &= \vec{n}\cdot\nabla u_2 = 0 ~~\textrm{ for }~~ \mathbf{x}\in\partial\Omega, \\ u_1(\mathbf{x}, 0) &= u_0(\mathbf{x}), \quad u_2(\mathbf{x}, 0) = 0, \end{aligned}

where vR2\mathbf{v}\in\mathbb{R}^{2} is a constant velocity field, κ1,κ2>0\kappa_1,\kappa_2 > 0 are the diffusion coefficients, α1,α2>0\alpha_1,\alpha_2 > 0 are the advection coefficients, ρ1,ρ2>0\rho_1,\rho_2 > 0 are the reaction coefficients, and f=f(x,t)f = f(\mathbf{x}, t) is a source term given by a sum of Gaussians:

f(x,t)=i=1nqqi(t)ϕi(x),ϕi(x)=50exp(1000xxˉi2),\begin{aligned} f(\mathbf{x},t) = \sum_{i=1}^{n_q}q_{i}(t)\phi_i(\mathbf{x}), \qquad\qquad \phi_i(\mathbf{x}) = 50 \exp\left(-1000\|\mathbf{x} - \bar{\mathbf{x}}_i\|^2\right), \end{aligned}

in which xˉiR2\bar{\mathbf{x}}_i\in\mathbb{R}^2 denotes the spatial center of the ii-th Gaussian.

The (time-discrete) controls in this problem are the values of the source nodes q1(t),,qm(t)q_1(t),\ldots,q_m(t) at the nt1n_t - 1 points of the temporal discretization, excluding the initial time point (because the eventual model is solved with the first-order implicit Euler time stepping scheme).

Control Problem

For a mesh with nxNn_x\in\mathbb{N} nodes, let y1(t),y2(t)Rnx\mathbf{y}_1(t),\mathbf{y}_2(t)\in\mathbb{R}^{n_x} denote the spatial discretizations of the PDE states u1u_1 and u2u_2, respectively, at time tt. The full discrete state is y(t)=[ y1(t)T  y2(t)T ]TRny\mathbf{y}(t) = [~\mathbf{y}_1(t)^{\mathsf{T}}~~\mathbf{y}_{2}(t)^{\mathsf{T}}~]^{\mathsf{T}}\in\mathbb{R}^{n_y} with ny=2nxn_y = 2n_x degrees of freedom. Let q(t)Rnq\mathbf{q}(t)\in\mathbb{R}^{n_q} collect the source node coefficients at time tt. We consider the following minimization:

miny(t),q(t)120Ty1(t)pM2dt+γ2t2Tq(t)22dtsubject to   (y(t),q(t))   jointly solves the PDE (1)and   qi(t)0   for all   i=1,,m   and   t>0.\begin{aligned} &\min_{\mathbf{y}(t), \mathbf{q}(t)} \frac{1}{2}\int_{0}^{T}\|\mathbf{y}_1(t) \ast \mathbf{p}\|_{\mathbf{M}}^2\,dt + \frac{\gamma}{2}\int_{t_2}^{T}\|\mathbf{q}(t)\|_2^2\,dt \\ &\textrm{subject to }~~(\mathbf{y}(t),\mathbf{q}(t))~~\textrm{ jointly solves the PDE (1)} \\ &\textrm{and }~~q_i(t) \ge 0~~\textrm{ for all }~~i=1,\ldots,m~~\textrm{ and }~~t > 0. \end{aligned}

Here, pRnx\mathbf{p}\in\mathbb{R}^{n_x} represents weights over the spatial domain indicating which areas should be prioritized to be protected from the contaminant, MRnx×nx\mathbf{M}\in\mathbb{R}^{n_x\times n_x} is a mass matrix, \ast denotes the Hadamard (elementwise) product, and γ>0\gamma > 0 is a user-specified regularization constant. The nonnegativity bound on the entries of q(t)\mathbf{q}(t) ensures that ff is a source (adding retardant in), never a sink (taking retardant out).

The Transient_ADR_2D_Objective class implements this objective, discretizing the time integrals with the trapezoidal rule as usual and setting p\mathbf{p} as a Gaussian. Note that the objective function only depends on y1(t)\mathbf{y}_1(t) (the contaminant) and u(t)\mathbf{u}(t) (the input), not on the retardant. We could penalize the amount of retardant as well (to model only having a limited amount of retardant), but a term like

0Ty2(t)M2dt\begin{aligned} \int_{0}^{T}\|\mathbf{y}_2(t)\|_{\mathbf{M}}^2\,dt \end{aligned}

would penalize the total amount of retardant (which decreases as it hits the contaminant), not the amount pumped into the system via the input.

Alternatively, we could penalize the controller with the term

γ2t2TBq(t)M2dt\begin{aligned} \frac{\gamma}{2}\int_{t_2}^{T}\|\mathbf{Bq}(t)\|_\mathbf{M}^2\,dt \end{aligned}

where B=[ ϕ1(x)    ϕm(x) ]Rnx×nq\mathbf{B} = [~\phi_1(\mathbf{x})~~\cdots~~\phi_m(\mathbf{x})~] \in\mathbb{R}^{n_x \times n_q}. This would be perhaps a better representation of the actual source term, but it requires more intrusive information (the B\mathbf{B} matrix) and has the same overall effect of penalizing the entries of q(t)\mathbf{q}(t).

Let g(y)=y1(t)pM2=(y1(t)p)TM(y1(t)p).g(\mathbf{y}) = \|\mathbf{y}_1(t) \ast \mathbf{p}\|_{\mathbf{M}}^2 = (\mathbf{y}_1(t) \ast \mathbf{p})^{\mathsf{T}}\mathbf{M}(\mathbf{y}_1(t) \ast \mathbf{p}). Assuming M\mathbf{M} is symmetric, these are the derivatives of gg:

 ⁣yg(y)=[ ⁣y1g(y) ⁣y2g(y)]=[p(M(y1p))0], ⁣ ⁣yg(y)=[ ⁣ ⁣y1g(y)000]=[pT^M^p000],\begin{aligned} \nabla_{\!\mathbf{y}}g(\mathbf{y}) = \left[\begin{array}{c} \nabla_{\!\mathbf{y}_1}g(\mathbf{y}) \\ \nabla_{\!\mathbf{y}_2}g(\mathbf{y}) \end{array}\right] = \left[\begin{array}{c} \mathbf{p}\ast(\mathbf{M}(\mathbf{y}_1\ast\mathbf{p})) \\ \mathbf{0} \end{array}\right], \\ \nabla_{\!\!\mathbf{y}}g(\mathbf{y}) = \left[\begin{array}{cc} \nabla_{\!\!\mathbf{y}_1}g(\mathbf{y}) & \mathbf{0} \\ \mathbf{0} & \mathbf{0} \end{array}\right] = \left[\begin{array}{cc} \mathbf{p}^{\mathsf{T}}\,\hat{\ast}\,\mathbf{M}\,\hat{\ast}\,\mathbf{p} & \mathbf{0} \\ \mathbf{0} & \mathbf{0} \end{array}\right], \end{aligned}

where ^\hat{\ast} denotes broadcasted multiplication.

Custom Mesh Generation

We’ve defined a rudimentary urban canyon mesh (two-dimensional with some holes) using MATLAB’s PDE Modeler tool. This section describes how to modify, export, and use the mesh.

> save('meshfile.mat', 'geometry', 'bcs', 'points', 'edges', 'triangles');

The static method Transient_ADR_2D.model_from_file() loads data from a .mat file and constructs a PDE model using the PDE Toolkit (which is different than the PDE Modeler in some ways). Once the model is initialized, use pdegplot(model,EdgeLabels="on") to show the geometry and edge labels.

Full-order Solver

The Transient_ADR_2D class uses MATLAB’s pde toolbox to solve the PDE (1) on the two-dimensional finite element mesh generated in the previous section. See the script Driver_Data_Generation.m for examples of generating and visualizing full-order solutions.

We can extract a mass matrix from this solver, but not the differential operators due to the velocity, reaction, and source terms.

To animate the solution instead of redoing the computation:

load('solver.mat', 'solver');
load('solution.mat', 'u');
solver.Animate_Solution(u.NodalSolution);

Reduced-order Modeling

The script Driver_Opt_OpInf_Multi.m uses the Transient_ADR_2D solver to generate training data, learn a POD basis, and calibrate a reduced-order model through Operator Inference:

ddty^1(t)=A^1y^1(t)+H^1[y^1y^2],ddty^2(t)=A^2y^2(t)+H^2[y^1y^2]+B^[q(t)q(t)],yi(t)V ⁣iy^i(t),i=1,2.\begin{aligned} \frac{\textrm{d}}{\textrm{d}t}\hat{\mathbf{y}}_1(t) &= \hat{\mathbf{A}}_1\hat{\mathbf{y}}_1(t) + \hat{\mathbf{H}}_1[\hat{\mathbf{y}}_1\otimes\hat{\mathbf{y}}_2], \\ \frac{\textrm{d}}{\textrm{d}t}\hat{\mathbf{y}}_2(t) &= \hat{\mathbf{A}}_2\hat{\mathbf{y}}_2(t) + \hat{\mathbf{H}}_2[\hat{\mathbf{y}}_1\otimes\hat{\mathbf{y}}_2] + \hat{\mathbf{B}}[\mathbf{q}(t) \ast \mathbf{q}(t)], \\ \mathbf{y}_i(t) &\approx \mathbf{V}_{\!i}\hat{\mathbf{y}}_{i}(t), \quad i=1,2. \end{aligned}

Here, ViRnx×ri\mathbf{V}_{i}\in\mathbb{R}^{n_x \times r_i} is a rank-rir_i POD basis learned from yi(t)\mathbf{y}_i(t) data. Note that r1r_1 and r2r_2 do not have to be equal. We then have reduced states yi(t)Rri\mathbf{y}_{i}(t) \in \mathbb{R}^{r_i} and “operators” A^iRri×ri\hat{\mathbf{A}}_i\in\mathbb{R}^{r_i\times r_i} and H^iRri×r1r2\hat{\mathbf{H}}_i\in\mathbb{R}^{r_i \times r_1 r_2} for i=1,2i=1,2, as well as B^Rr2×nq\hat{\mathbf{B}}\in\mathbb{R}^{r_2 \times n_q}. The Hadamard product \ast is employed to ensure positivity in the control.

The regression is populated with sixth-order finite difference estimates of the time derivatives of the training states.

Remark. We could specify B^\hat{\mathbf{B}} intrusively by constructing the control matrix B\mathbf{B} and setting B^=V2TB\hat{\mathbf{B}} = \mathbf{V}_{2}^\mathsf{T}\mathbf{B}. Likewise, since the nonlinearity u1u2u_1 u_2 is local, we can construct each H^i\hat{\mathbf{H}}_i intrusively if we like. We want H^i\hat{\mathbf{H}}_i such that

H^i[y^1y^2]=ViT((V ⁣1y^1)(V ⁣2y^2))\begin{aligned} \hat{\mathbf{H}}_{i}[\hat{\mathbf{y}}_1\otimes\hat{\mathbf{y}}_2] &= \mathbf{V}_{i}^\mathsf{T}((\mathbf{V}_{\!1}\hat{\mathbf{y}}_1)\ast(\mathbf{V}_{\!2}\hat{\mathbf{y}}_2)) \end{aligned}

It turns out that

(V ⁣1y^1)(V ⁣2y^2)=(V ⁣1TV ⁣2T)T[y^1y^2],\begin{aligned} (\mathbf{V}_{\!1}\hat{\mathbf{y}}_1)\ast(\mathbf{V}_{\!2}\hat{\mathbf{y}}_2) = (\mathbf{V}_{\!1}^{\mathsf{T}} \odot \mathbf{V}_{\!2}^{\mathsf{T}})^\mathsf{T}[\hat{\mathbf{y}}_1\otimes\hat{\mathbf{y}}_2], \end{aligned}

where \odot is the Khatri-Rao product. Hence, H^i=ViT(V ⁣1TV ⁣2T)T\hat{\mathbf{H}}_i = \mathbf{V}_i^\mathsf{T}(\mathbf{V}_{\!1}^{\mathsf{T}} \odot \mathbf{V}_{\!2}^{\mathsf{T}})^\mathsf{T}.

(This is a mixed-product property, proven in Theorem 1 of this paper).

Detailed Guide of Driver_Opt_OpInf_Multi.m

This section explains Driver_Opt_OpInf_Multi.m, a script that

  1. Generates full-order solutions to be used as training data,

  2. Trains an OpInf ROM with the Transient_ADR_2D_OpInf_Constraint class,

  3. Solves an optimization problem using the ROM as a surrogate, and

  4. Visualizes results.

Preliminaries

clear;
close all;
clc;

%% Experiment parameters.

meshfile = 'urban_canyon.mat';
datafile = 'OpInf_Training_Data.mat';

regenerate_data = false;
plot_basis_functions = false;
plot_training_data = false;
plot_training_reconstruction = false;

residual_energies = [1e-3];
ABregularization_candidates = [0, 1, 10, 100];
Hregularization_candidates = logspace(2, 6, 21);
ddt_strategy = '6thOrder';
control_regularization = 5e-2;

Data variables:

Script directives:

OpInf parameters:

Optimization parameters:

Generate full-order solutions

%% Generate training data if needed.

if ~exist(datafile, 'file') || regenerate_data
    disp('Generating training data');

    tic();
    % Initial condition parameters.
    init_center = [.05; .85];
    num_solves = 5;

    % Input function parameters.
    control_nodes = [0.1 0.5
                     0.1 0.9
                     0.1 1.1
                     0.3 0.7
                     0.3 0.9
                     0.3 1.1
                     0.5 0.3
                     0.5 0.5
                     0.5 0.7
                     0.7 0.7
                     0.9 0.3
                     0.9 1.1
                     1.1 0.7
                     1.1 0.9]';
    n_q = size(control_nodes, 2);
    % Time domain.
    t = linspace(0, .4, 101);
    n_t = length(t);
    n_z = (n_t - 1) * n_q;

    % Load spatial geometry and mesh.
    model = Transient_ADR_2D.model_fromfile(meshfile);
    n_x = size(model.Mesh.Nodes, 2);
    n_y = 2 * n_x;
    n_u = n_y * n_t;

    % Model and input parameters.
    diffusion = [0.10, 0.10];
    advection = [4.00, 4.00];
    reaction = 2;
    num_randcontrol_nodes = 4;
    randcontrol_nodes = linspace(t(1), t(end), num_randcontrol_nodes);
    Z_train = zeros(n_z, num_solves);
    U_train = zeros(n_u, num_solves);

    for k = 1:num_solves
        disp(['High-fidelity solve ', num2str(k)]);

        % Initialize the solver.
        solver = Transient_ADR_2D(model, init_center, ...
                                  diffusion, advection, reaction, control_nodes);

        % Set up a random control profile.
        vals = [zeros(n_q, 1), 50 * rand(n_q, num_randcontrol_nodes - 1)];
        pp = spline(randcontrol_nodes, vals);
        controller = @(tt) ppval(pp, tt);

        % Solve the system.
        Yk = solver.State_Solve(controller, t).NodalSolution;

        if plot_training_data
            solver.Animate_Solution(Yk);
        end

        Qk = controller(t(2:end));

        % Record results.
        U_train(:, k) = reshape(Yk, [], 1);
        Z_train(:, k) = reshape(Qk, [], 1);
    end
    time_trainingdata = toc();

    save(datafile, "t", "solver", "U_train", "Z_train", "time_trainingdata");
end
%% Load training data.

load(datafile);
n_t = length(t);
T = t(end);
n_u = size(U_train, 1);
num_solves = size(U_train, 2);
n_y = n_u / n_t;
n_x = n_y / 2;  % = size(solver.model.Mesh.Nodes, 2);
mass_matrix = assembleFEMatrices(solver.model, 'M').M;
mass_matrix = mass_matrix(1:n_x, 1:n_x);
stiffness_matrix = assembleFEMatrices(solver.model, 'K').K;
stiffness_matrix = stiffness_matrix(1:n_x, 1:n_x);

n_z = size(Z_train, 1);
n_q = n_z / (n_t - 1);  % = solver.n_q;
fprintf('Using %d training trajectories\n', num_solves);
%% Learn a POD basis for each variable.

% Unpack the states and controls by training trajectory.
states = cell(num_solves);
controls = cell(num_solves);
controls_romtraining = cell(num_solves);
for k = 1:num_solves
    states{k} = reshape(U_train(:, k), n_y, n_t);
    controls{k} = reshape(Z_train(:, k), n_q, n_t - 1);
    controls_romtraining{k} = sqrt(abs(controls{k}));
end

% Learn POD bases from the collection of all state snapshots.
states_all = horzcat(states{:});
basis1 = POD_Basis(states_all(1:n_x, :), false);  % , full(mass_matrix));
basis1.Set_Reduced_Dimension_From_Residual_Energy(residual_energies(1));
basis2 = POD_Basis(states_all(n_x + 1:end, :), false);  % , full(mass_matrix));
basis2.Set_Reduced_Dimension_From_Residual_Energy(residual_energies(1));
if plot_basis_functions
    for i = 1:min(basis1.r, basis2.r)
        solver.Plot_Field([basis1.V(:, i), basis2.V(:, i)]);
        title(['POD basis function ', num2str(i)]);
    end
end

if plot_training_data
    for k = 1:num_solves
        Yhatk_1 = basis1.Compress(states{k}(1:n_x, :));
        Yhatk_2 = basis2.Compress(states{k}(n_x + 1:end, :));
        Yhatk = [Yhatk_1; Yhatk_2];
        figure;
        plot(t, Yhatk);
        title(['compressed state training data, trajectory', num2str(k)]);
    end
end

Train an OpInf ROM

The next block loops through the residual_energies and does OpInf for each choice of residual energy, which dictates the number of POD basis vectors for each state.

%% Learn a ROM, varying the reduced state dimension.

errors = zeros(length(residual_energies), 1);
for i = 1:length(residual_energies)
    res_energy = residual_energies(i);
    fprintf('\nUsing %.2e residual energy\n', res_energy);

    basis1.Set_Reduced_Dimension_From_Residual_Energy(res_energy);
    basis2.Set_Reduced_Dimension_From_Residual_Energy(res_energy);
    r_1 = basis1.r;
    r_2 = basis2.r;
    n_yr = r_1 + r_2;
    fprintf('POD with r_1 = %d and r_2 = %d basis vectors\n', r_1, r_2);

    % Compress states and check projection error.
    states_lofi = cell(num_solves);
    for k = 1:num_solves
        Yhat_1 = basis1.Compress(states{k}(1:n_x, :));
        Yhat_2 = basis2.Compress(states{k}(n_x + 1:end, :));
        states_lofi{k} = [Yhat_1; Yhat_2];
        Yproj_1 = basis1.Decompress(Yhat_1);
        Yproj_2 = basis2.Decompress(Yhat_2);
        Yproj = [Yproj_1; Yproj_2];
        proj_err = norm(Yproj - states{k}) / norm(states{k});
        fprintf("Projection error of trajectory %d: %.4f%%\n", k, 100 * proj_err);
    end
    %% Learn an OpInf ROM from the data.

    rom = Transient_ADR_2D_OpInf_Constraint(r_1, r_2, n_q, T, n_t, zeros(n_yr, 1));
    tic();
    rom.Select_Regularization(states_lofi, controls_romtraining, ...
                              ABregularization_candidates, ...
                              Hregularization_candidates, ...
                              ddt_strategy);
    time_opinfcalibration = toc();

The Select_Regularization method estimates the time derivatives of the training states (ddt_strategy), uses each combination of entries from ABregularization_candidates and Hregularization_candidates to define Tikhonov regularizers, and solves the ROM for each set of training controls. The regularizer that produces the least training error is deemed the winner.

    % Solve the ROM for each of the training controls.
    total_error = 0;
    for k = 1:num_solves
        Yk_data = states{k};
        rom.y0 = states_lofi{k}(:, 1);
        Yk_rom_compressed = rom.State_Solve2(controls_romtraining{k});
        Yk_rom_1 = basis1.Decompress(Yk_rom_compressed(1:r_1, :));
        Yk_rom_2 = basis2.Decompress(Yk_rom_compressed(r_1 + 1:end, :));
        Yk_rom = [Yk_rom_1; Yk_rom_2];
        state_error = norm(Yk_data - Yk_rom) / norm(Yk_data);
        fprintf('ROM reconstruction error for training set %d: %.2f%%\n', ...
                k, 100 * state_error);
        total_error = total_error + state_error;
        if plot_training_reconstruction
            solver.Animate_Solution(Yk_rom);
        end
    end
    errors(i) = total_error / num_solves;
end

if length(residual_energies) > 1
    figure;
    semilogx(residual_energies, errors);
    title('Residual energy versus average ROM training error');
end

Optimization with the OpInf ROM

%% Set up the optimization objective.

% Make sure the initial conditions are right.
solver.init_center = [.05; .85];
rom.y0 = states_lofi{1}(:, 1);

obj_hifi = solver.Make_Objective([.6; .6], t(end), length(t), control_regularization);
Vfull = blkdiag(basis1.V, basis2.V);
obj_lofi = Reduced_Dynamic_Objective(obj_hifi, Vfull);
solver.Plot_Field(obj_hifi.target_weight, 'Protection zone');
%% Set up and solve the optimization problem (using last trained ROM).

opt = Reduced_Space_Optimization(obj_lofi, rom);
% opt.z_lb = zeros(n_z, 1);                   % Lower bounds for control.
% opt.z_ub = 25 * ones(n_z, 1);               % Upper bounds for control.
opt.max_cg_iter = 200;

tic();
[u_lofi, z_lofi] = opt.Optimize(rand(n_z, 1));
time_lofioptimization = toc();

Visualize Results

%% Visualize optimization results.

% Inspect the state solution.
u_lofi_reshape = reshape(u_lofi, n_yr, n_t);
Y_rom_1 = basis1.Decompress(u_lofi_reshape(1:r_1, :));
Y_rom_2 = basis2.Decompress(u_lofi_reshape(r_1 + 1:end, :));
Y_rom = [Y_rom_1; Y_rom_2];
solver.Animate_Solution(Y_rom);             % ROM state with ROM controller

% Inspect the control solution.
Q_rom = reshape(z_lofi, n_q, n_t - 1).^2;
figure;
plot(t(2:end), Q_rom);
title('Optimal controls (optimized with an OpInf ROM surrogate)');
% Solve the high-fidelity model with the inferred controls.
disp('Final high-fidelity solve');
pp = spline(t(2:end), Q_rom);
controller = @(tt) ppval(pp, tt);
Y_hifi = solver.State_Solve(controller, t).NodalSolution;
solver.Animate_Solution(Y_hifi);            % FOM state with ROM controller

save('OptimizationSolution.mat', "solver", "Y_hifi", "Y_rom", "t", "Q_rom", "n_q");

%% Load and visualize results later.
% load('OptimizationSolution.mat', "solver", "Y_hifi", "Y_rom", "t", "Q_rom", "n_q");
% figure;
% plot(t(2:end), Q_rom);
% title('Optimal controls (optimized with an OpInf ROM surrogate)');
% solver.Animate_Solution(Y_rom);   % ROM state with ROM controller
% solver.Animate_Solution(Y_hifi);  % FOM state with ROM controller

This final block solves the high-fidelity system with the control profile produced by the surrogate-driven optimization. The results are saved for later, and you can load the results using the last bit of commented code.

References
  1. Hart, J., McQuarrie, S. A., Morrow, Z., & van Bloemen Waanders, B. (2026). Toward real-time optimization through model reduction and model discrepancy sensitivities. Structural and Multidisciplinary Optimization, 39(2).
  2. Slyusar, V. I. (1999). A family of face products of matrices and its properties. Cybernetics and Systems Analysis, 35(3), 379–384. 10.1007/bf02733426