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..3space.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.