Partial configs and driver loops#

A configuration does not have to arrive all at once. A wizard-style UI, or a solver that asks one question at a time, builds one incrementally and needs to know at each step what is still open and what is already determined. This page uses a pump configurator, whose impeller diameter is bounded by the flow rate that was actually assigned.

import designspace as ds

space = (
    ds.space(
        ds.param("flow_rate_lpm").real(100.0, 500.0),
        # An expression bound: the upper limit is a parameter reference.
        ds.param("impeller_diameter_mm").real(20.0, ds.param("flow_rate_lpm")),
        ds.param("num_stages").integer(1, 5),
        ds.param("seal_type").categorical("mechanical", "packing", "magnetic"),
        ds.param("certifications").subset(("CE", "UL", "ATEX"), min_size=0),
        ds.param("stage_order").permutation(("intake", "boost", "discharge")),
        ds.param("vibration_profile").real(0.0, 1.0).repeat(4),
    )
    .forbid(ds.param("seal_type") == "packing")
    .forbid(
        (ds.param("seal_type") == "magnetic")
        & ds.param("certifications").contains("ATEX"),
    )
)
print(space)
Space: 7 params, 0 conditional, 3 constraints
  flow_rate_lpm         real         [100.0, 500.0]
  impeller_diameter_mm  real         [20.0, 500.0]
  num_stages            integer      [1, 5]
  seal_type             categorical  {'mechanical', 'packing', 'magnetic'}
  certifications        subset       subset of {'CE', 'UL', 'ATEX'}, size 0..3
  stage_order           permutation  ordering of {'intake', 'boost', 'discharge'}
  vibration_profile     list         count = 4, of real [0.0, 1.0]

  bound   impeller_diameter_mm <= flow_rate_lpm
  forbid  seal_type == 'packing'
  forbid  seal_type == 'magnetic' and 'ATEX' in certifications

Defaults#

A default is a per-parameter fill value. apply_defaults completes whatever is missing and leaves supplied values alone.

defaulted = ds.space(
    ds.param("workers").integer(1, 16).default(4),
    ds.param("batch").integer(8, 512).default(32),
)
defaulted.apply_defaults({"workers": 12})
{'workers': 12, 'batch': 32}

apply_defaults fills parameters and does not consult constraints, so its output can be infeasible. That is deliberate: filling and validating are separate steps, and the library does not guess which parameter to move.

conflicting = ds.space(
    ds.param("a").integer(0, 10).default(9),
    ds.param("b").integer(0, 10).default(9),
).forbid(ds.param("a") + ds.param("b") > 10)
filled = conflicting.apply_defaults({})
print(ds.pretty(filled, conflicting))
Config: 2 params, 2 set, 0 inactive, INVALID
  a  = 9  in [0, 10]
  b  = 9  in [0, 10]

  forbid  a + b > 10  violated  margin 8.000

What is open#

param_activity classifies every parameter for a partial configuration.

space.param_activity({})
{'flow_rate_lpm': 'active',
 'impeller_diameter_mm': 'active',
 'num_stages': 'active',
 'seal_type': 'active',
 'certifications': 'active',
 'stage_order': 'active',
 'vibration_profile': 'active',
 'vibration_profile[0]': 'active',
 'vibration_profile[1]': 'active',
 'vibration_profile[2]': 'active',
 'vibration_profile[3]': 'active'}

evaluate_partial reports how much is left and which constraints can already be judged.

pe = space.evaluate_partial({"seal_type": "mechanical"})
pe.n_remaining, len(pe.evaluable_constraints), len(pe.pending_constraints)
(9, 2, 1)

What values remain#

remaining_domain narrows one parameter’s domain given what is already assigned. It returns one of five descriptor kinds.

space.remaining_domain("flow_rate_lpm", {})
[100.0, 500.0]

The expression bound tightens once the parameter it references is known:

space.remaining_domain("impeller_diameter_mm", {"flow_rate_lpm": 300.0})
[20.0, 300.0]

A forbid over a single unset operand reduces fully, so packing is already excluded:

space.remaining_domain("seal_type", {})
{'mechanical', 'magnetic'}

The other two kinds:

space.remaining_domain("certifications", {"seal_type": "magnetic"})
forced {}, free {'CE', 'UL', 'ATEX'}, size 0..3
space.remaining_domain("stage_order", {})
ordering of {'intake', 'boost', 'discharge'}

remaining_domain is sound but not complete: it never excludes a value that is still feasible, but it does not reduce every constraint. The compound forbid above is a conjunction across two parameters, which is not the single-unset- operand shape it reduces, so ATEX is still listed as available.

Validating one value in context#

validate_param checks a value on its own. Where a constraint depends on another parameter, it is omitted as under-determined rather than guessed:

space.validate_param("impeller_diameter_mm", 350.0).valid
True

Supplying the context evaluates it:

space.validate_param(
    "impeller_diameter_mm", 350.0, context={"flow_rate_lpm": 300.0}
).valid
False

The driver loop#

next_assignable names what can be assigned now, is_complete says when to stop, and missing_params reports what is still absent.

The loop builds its configuration keyed by path, which is the form next_assignable reports in, so each value is written at the path it was just given. A lift’s elements are named one at a time, and assigned one at a time like everything else.

target = space.sample_one(seed=0)
flat = ds.flatten(target, space)

partial = {}
steps = []
while not space.is_complete(partial):
    path = space.next_assignable(partial)[0]
    partial[path] = flat[path]
    steps.append(path)
steps
['flow_rate_lpm',
 'impeller_diameter_mm',
 'num_stages',
 'seal_type',
 'certifications',
 'stage_order',
 'vibration_profile[0]',
 'vibration_profile[1]',
 'vibration_profile[2]',
 'vibration_profile[3]']
space.is_complete(partial), space.missing_params(partial), space.is_feasible(partial)
(True, [], True)

ds.unflatten returns the nested form, which is what validate and sample_one speak:

ds.unflatten(partial, space) == target
True

The two forms are told apart by ds.is_flat, and ds.flatten refuses a configuration that is already keyed by path rather than quietly dropping the lifts it cannot flatten twice:

ds.is_flat(partial, space), ds.is_flat(target, space)
(True, False)

A count decided during the loop#

Above, the lift’s length was fixed at declaration. When a count is itself a parameter, the elements cannot be named until it has a value, and the loop reflects that: first the count, then the instances it brought into being.

pipeline = ds.space(
    ds.param("n_stages").integer(0, 3),
    ds.param("stages")
    .space(ds.space(ds.param("dwell_s").integer(1, 60)))
    .repeat(ds.param("n_stages")),
)
pipeline.next_assignable({})
['n_stages']
pipeline.next_assignable({"n_stages": 2})
['stages[0].dwell_s', 'stages[1].dwell_s']

A count of zero is the case worth knowing. An active lift is present whatever its length, carrying [] when it has no elements, because absence is what marks a lift inactive. With no element to assign, the list itself is what becomes assignable, and it is the only time a container is:

pipeline.next_assignable({"n_stages": 0})
['stages']

Assigned in the flat form, a list is written as its length:

empty = {"n_stages": 0, "stages": 0}
pipeline.is_complete(empty), ds.unflatten(empty, pipeline)
(True, {'n_stages': 0, 'stages': []})

Positional vectors#

Where a space is fixed-layout, meaning every count is static and no parameter carries a condition, coordinate_paths() gives the flat keys that are coordinates, in a stable order.

paths = space.coordinate_paths()
paths
('flow_rate_lpm',
 'impeller_diameter_mm',
 'num_stages',
 'seal_type',
 'certifications',
 'stage_order',
 'vibration_profile[0]',
 'vibration_profile[1]',
 'vibration_profile[2]',
 'vibration_profile[3]')

That is enough to pack a configuration into a vector and back:

vector = [flat[p] for p in paths]
restored = ds.unflatten(dict(zip(paths, vector, strict=True)), space)
restored == target
True

Deriving that key set by hand fails silently, because a hand-rolled filter cannot tell a coordinate from a count-bookkeeping entry without walking the ListDomain chain. The method is undefined where the layout depends on the configuration, and says so rather than returning a set that would be wrong for some draws:

conditional = ds.space(
    ds.param("flag").bool(),
    ds.param("x").real(0.0, 1.0).when(ds.param("flag")),
)
try:
    conditional.coordinate_paths()
except ds.ResolutionError as exc:
    print(exc)
coordinate_paths(): 'x' carries a condition, so the space has no fixed layout

Where to go next#

Diagnostics and DataFrames looks at a space in aggregate rather than one configuration at a time.