Array Variables

This notebook demonstrates how to define and use ArrayVariable for multi-dimensional inputs and outputs in the Standard Evaluator library. You will learn how ArrayVariable extends FloatVariable with shape information, how array data is represented in both rolled (NumPy array in a DataFrame cell) and unrolled (individual scalar columns) forms, and how array evaluators integrate with OpenMDAO.

Overview: ArrayVariable Fields and Concepts

ArrayVariable is a subclass of FloatVariable that represents multi-dimensional NumPy arrays. It adds the following fields:

Field

Type

Description

shape

tuple[int, ...]

Shape of the array (e.g., (3,) for 1-D, (2, 3) for 2-D)

bounds

tuple[array, array]

Lower and upper bounds — can be scalar (broadcast to shape) or full arrays

shift

float | array

Shift value(s) for the variable (broadcast to shape if scalar)

scale

float | array

Scale value(s) for the variable (broadcast to shape if scalar, cannot be zero)

default

ndarray

Default values as a NumPy array matching the shape

units

str

Units of the variable

Relationship to FloatVariable

ArrayVariable inherits from FloatVariable and reuses its name validation and general structure, but replaces scalar bounds/shift/scale with array-aware versions. While a FloatVariable represents a single scalar value, an ArrayVariable represents an entire NumPy array as a single logical variable.

Rolled vs Unrolled Representations

  • Rolled: The array is stored as a single NumPy array inside one DataFrame cell. This is what the __call__ method expects and returns.

  • Unrolled: Each element of the array becomes its own scalar column (e.g., arr[0], arr[1], arr[2] for a shape (3,) array). This is how eval_np and eval_list receive and return data.

Setup

We import the Standard Evaluator library and supporting packages.

import standard_evaluator as se
from standard_evaluator.evaluators.numpy_evaluator import NumpyEvaluator
from standard_evaluator.components import EvaluatorOpenMdaoComponent
import openmdao.api as om
import numpy as np
import pandas as pd
matlabengine not available.

Creating ArrayVariables

We create a 1-D ArrayVariable representing a 3-element vector and a 2-D ArrayVariable representing a 2×3 matrix. Both have explicit bounds set.

# 1-D ArrayVariable: a vector of length 3
vec_var = se.ArrayVariable(
    name="vec",
    shape=(3,),
    bounds=(-10.0, 10.0),
    default=np.array([1.0, 2.0, 3.0]),
)

print("1-D ArrayVariable:")
print(f"  Name: {vec_var.name}")
print(f"  Shape: {vec_var.shape}")
print(f"  Bounds lower: {vec_var.bounds[0]}")
print(f"  Bounds upper: {vec_var.bounds[1]}")
print(f"  Default: {vec_var.default}")
print()

# 2-D ArrayVariable: a 2x3 matrix
mat_var = se.ArrayVariable(
    name="matrix",
    shape=(2, 3),
    bounds=(0.0, 100.0),
    default=np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]),
)

print("2-D ArrayVariable:")
print(f"  Name: {mat_var.name}")
print(f"  Shape: {mat_var.shape}")
print(f"  Bounds lower:\n{mat_var.bounds[0]}")
print(f"  Bounds upper:\n{mat_var.bounds[1]}")
print(f"  Default:\n{mat_var.default}")
1-D ArrayVariable:
  Name: vec
  Shape: (3,)
  Bounds lower: [-10. -10. -10.]
  Bounds upper: [10. 10. 10.]
  Default: [1. 2. 3.]

2-D ArrayVariable:
  Name: matrix
  Shape: (2, 3)
  Bounds lower:
[[0. 0. 0.]
 [0. 0. 0.]]
  Bounds upper:
[[100. 100. 100.]
 [100. 100. 100.]]
  Default:
[[1. 2. 3.]
 [4. 5. 6.]]

The scalar bounds (-10.0, 10.0) and (0.0, 100.0) are automatically broadcast to match the array shape. You can also pass full NumPy arrays for per-element bounds.

Using ArrayVariables in a NumpyEvaluator

We define an evaluator that takes a 3-element vector input and produces a 3-element vector output by squaring each element. This demonstrates how __call__ (rolled) and eval_np (unrolled) handle array data differently.

# Define the optimization problem with array input and output
opt_problem = se.OptProblem(
    name="array_demo",
    variables=[
        se.ArrayVariable(
            name="x",
            shape=(3,),
            bounds=(-5.0, 5.0),
            default=np.array([1.0, 2.0, 3.0]),
        ),
    ],
    responses=[
        se.ArrayVariable(
            name="y",
            shape=(3,),
            bounds=(-np.inf, np.inf),
        ),
    ],
    objectives=[],
    constraints=[],
)


# Implement the evaluator: y = x^2 (element-wise)
class SquareArray(NumpyEvaluator):
    """Computes y = x^2 element-wise for a 3-element vector."""

    def eval_np(self, sites, **kwargs):
        # sites is unrolled: shape (n_sites, 3) where columns are x[0], x[1], x[2]
        return sites ** 2


evaluator = SquareArray(opt_problem=opt_problem)
print("Inputs:", evaluator.inputs)
print("Outputs:", evaluator.outputs)
Inputs: ['x']
Outputs: ['y']

Rolled Invocation via __call__

With __call__, we pass a DataFrame where array columns contain full NumPy arrays in each cell. The evaluator returns results in the same rolled format.

# Create rolled input DataFrame: each cell in the 'x' column holds a NumPy array
sites_rolled = pd.DataFrame({"x": [np.array([1.0, 2.0, 3.0]), np.array([4.0, -1.0, 0.5])]})

# Call the evaluator (modifies DataFrame in place)
evaluator(sites_rolled)

print("Rolled results (arrays in DataFrame cells):")
print(sites_rolled)
Rolled results (arrays in DataFrame cells):
                  x                  y
0   [1.0, 2.0, 3.0]    [1.0, 4.0, 9.0]
1  [4.0, -1.0, 0.5]  [16.0, 1.0, 0.25]

Each cell in the y column contains a NumPy array with the squared values. The first row computes [1, 4, 9] and the second computes [16, 1, 0.25].

Unrolled Invocation via eval_np

With eval_np, input and output arrays are unrolled into individual scalar columns. A shape (3,) array becomes 3 separate columns in the input and output NumPy arrays.

# Unrolled input: each row has 3 scalar values (x[0], x[1], x[2])
sites_unrolled = np.array([
    [1.0, 2.0, 3.0],
    [4.0, -1.0, 0.5],
])

# eval_np receives and returns unrolled numpy arrays
results_unrolled = evaluator.eval_np(sites_unrolled)

print("Unrolled input (shape", sites_unrolled.shape, "):")
print(sites_unrolled)
print()
print("Unrolled output (shape", results_unrolled.shape, "):")
print(results_unrolled)
Unrolled input (shape (2, 3) ):
[[ 1.   2.   3. ]
 [ 4.  -1.   0.5]]

Unrolled output (shape (2, 3) ):
[[ 1.    4.    9.  ]
 [16.    1.    0.25]]

The unrolled output is a 2D NumPy array where each column corresponds to y[0], y[1], y[2]. Both approaches produce the same mathematical result, just in different data representations.

OpenMDAO Integration with Array Variables

Array evaluators integrate seamlessly with OpenMDAO via EvaluatorOpenMdaoComponent. Array variables are registered as OpenMDAO inputs/outputs with their full shape.

# Wrap the array evaluator as an OpenMDAO component
prob = om.Problem()
prob.model.add_subsystem(
    "comp", EvaluatorOpenMdaoComponent(evaluator), promotes=["*"]
)
prob.setup()

# Set array input and run
prob.set_val("x", np.array([2.0, 3.0, 4.0]))
prob.run_model()

print(f"Input x = {prob.get_val('x')}")
print(f"Output y = x^2 = {prob.get_val('y')}")
print()

# Verify shapes are registered correctly
comp = prob.model.comp
input_meta = comp.get_io_metadata(iotypes="input", metadata_keys=["shape"])
output_meta = comp.get_io_metadata(iotypes="output", metadata_keys=["shape"])
for name, meta in input_meta.items():
    print(f"Input '{meta['prom_name']}': shape = {meta['shape']}")
for name, meta in output_meta.items():
    print(f"Output '{meta['prom_name']}': shape = {meta['shape']}")
Input x = [2. 3. 4.]
Output y = x^2 = [ 4.  9. 16.]

Input 'x': shape = (3,)
Output 'y': shape = (3,)

The component correctly computes y = [4, 9, 16] for input x = [2, 3, 4]. OpenMDAO registers both input and output with shape (3,), matching the ArrayVariable definition.

Unrolled Name Generation

When array variables are unrolled (for eval_np, eval_list, or internal processing), each element gets a unique name following NumPy indexing syntax:

1-D Arrays

For a variable named input with shape (3,), the unrolled names are:

  • input[0]

  • input[1]

  • input[2]

Multi-dimensional Arrays

For a variable named matrix with shape (2, 3), the unrolled names follow row-major (C-order) iteration:

  • matrix[0,0], matrix[0,1], matrix[0,2]

  • matrix[1,0], matrix[1,1], matrix[1,2]

The generate_names function and the OptProblem.unroll_names method produce these names.

# Demonstrate unrolled name generation
from standard_evaluator.problem import generate_names

# 1-D names
names_1d = generate_names("input", (3,))
print("1-D unrolled names:", names_1d)

# 2-D names
names_2d = generate_names("matrix", (2, 3))
print("2-D unrolled names:", names_2d)

# Using OptProblem.unroll_names
print()
print("Unrolled variable names from opt_problem:")
print(opt_problem.unroll_names(opt_problem.variables))
print("Unrolled response names from opt_problem:")
print(opt_problem.unroll_names(opt_problem.responses))
1-D unrolled names: ['input[0]', 'input[1]', 'input[2]']
2-D unrolled names: ['matrix[0,0]', 'matrix[0,1]', 'matrix[0,2]', 'matrix[1,0]', 'matrix[1,1]', 'matrix[1,2]']

Unrolled variable names from opt_problem:
['x[0]', 'x[1]', 'x[2]']
Unrolled response names from opt_problem:
['y[0]', 'y[1]', 'y[2]']

The unrolled names use comma-separated indices in brackets, following NumPy’s multi-dimensional indexing convention. This naming scheme is used internally when converting between rolled and unrolled representations.