Skip to content

Parameter Binding

How a value typed into a recipe ends up driving a specific tag on a specific control module on a specific unit. This is the "how do recipes connect to hardware" question that's implicit everywhere in the library but never spelled out in one place.

The five-layer binding chain

A running batch step touches five layers of binding. From the recipe down to the wire:

graph TD
    A["Recipe parameter<br/>(reactorChargeRate = 75)"] -->|"parameterBindings map<br/>(MasterRecipe/ControlRecipe)"| B["Recipe step<br/>(charge-reactor calls fillPercent = 75)"]
    B -->|"templateRef + templateKind"| C["PhaseTemplate parameter<br/>(charge-reactor.fillPercent)"]
    C -->|"step actionST<br/>WRITE&#40;'solvent_valve.CMD', fillPercent&#41;"| D["ControlModule role<br/>(solvent_valve)"]
    D -->|"Unit.spec.cmRoles map"| E["ControlModule instance<br/>(r1-solvent-valve)"]
    E -->|"ControlModule tagBindings<br/>valve_out: reactor-sim:analog.6"| F["IOModule channel<br/>(reactor-sim analog.6)"]

Each arrow is a separate binding mechanism, with its own location in the YAML and its own validation rules. Understanding them in order lets you trace any recipe parameter all the way down to the wire, and lets you debug a "phase ran but the valve didn't move" failure by walking the chain.

Concrete worked example: tracing fillPercent

You can trace one parameter end-to-end through a real example recipe, sim-api-isolation-m1.

The same walk, filmed on the plant-01 fermentation plant: the recipe's parameter declarations, the procedure viewer's bindings panel, the phase contract, the Unit's role map, the instance's tag binding, and then a batch created with a formula value that comes back out of the pre-start preview.

The five layers in one take on plant-01: declaration → binding → contract → role map → wire, and the formula-picked value read back from the un-started batch's preview.

Layer 1 — Recipe parameter declaration

The recipe declares its top-level parameters in spec.parameters.

/system → Recipes → Master Recipes → pick sim-api-isolation-m1Parameters section. Each row is one declared parameter: name, type, default, min, max, engineering unit, and description. + Add Parameter adds a row when editing.

Master Recipe edit form: Parameters section with name / type / default / min / max / unit columns

# examples/riverbend/19-api-isolation-recipes.yaml — MasterRecipe sim-api-isolation-m1
spec:
  parameters:
    - name: reactorChargeRate
      type: REAL
      defaultValue: "75"
      minValue: "10"
      maxValue: "100"
      engineeringUnit: "%"
      description: "Reactor inlet valve opening during charge"
    # ... many more parameters ...

These are the knobs that get set at batch creation time. The create form itself carries no per-parameter inputs, and a batch's values arrive one of two ways. MasterRecipes define named parameter sets via spec.formulas (RecipeFormula). Each formula is a collection of parameter name-value pairs pre-tuned for a specific batch size or product grade, and the + NewBatch form offers a formula dropdown whenever the recipe defines any. Alternatively, Batch.spec.parameterOverrides overrides individual values on the API/YAML path. A parameter no formula or override touches runs at its defaultValue. See Batch Execution for both mechanisms.

Layer 2 — Recipe step parameter binding

Down in the procedure, a recipe step references a phase template and binds each of the phase template's parameters to a recipe-level parameter. There are two forms depending on which recipe level you're looking at.

MasterRecipe and ControlRecipe use a parameterBindings map (phase-parameter: recipe-parameter).

On the same MasterRecipe detail view, the procedure viewer's Parameter Bindings panel lists every step's bindings: the step parameter, the recipe parameter it binds to, and the value it currently resolves to. The bindings themselves are recipe YAML: Edit SFC opens the procedure editor, which preserves a step's parameterBindings on save, but rewriting one is an apply-path edit.

SFC editor: master-recipe procedure opened from the recipe detail, with the unit-procedure step selected and its properties panel on the right

# examples/riverbend/19-api-isolation-recipes.yaml — MasterRecipe sim-api-isolation-m1
procedure:
  chart:
    steps:
      - name: charge-reactor
        description: "Transfer solution from tank to reactor"
        templateRef: charge-reactor
        templateKind: PhaseTemplate
        targetCapability: reaction
        parameterBindings:
          fillPercent: reactorChargeRate
          duration: reactorChargeDuration

When does substitution happen? Not at dcs recipe approve. The batch operator resolves the bindings when it instantiates a ControlRecipe from the MasterRecipe at batch creation (internal/controller/batch/recipe_instantiator.go). The ControlRecipe is the post-substitution snapshot, the literal values that actually drive the run. If the operator started this batch with reactorChargeRate = 75, the resulting ControlRecipe step has fillPercent: 75 literally embedded, and that is what flows to the phase's input.

Substitution is recursive. Nested OperationTemplate and UnitProcedureTemplate parameters are substituted the same way, so the chain above can be deeper than five layers when a recipe calls through OperationTemplates.

Layer 3 — PhaseTemplate parameter declaration

The phase template declares the parameter on its end.

/system → Equipment Library → Phases → click charge-reactor. The Parameters section shows every declared parameter (name, type, required, min, max, default, unit). Edit on the detail view rewrites the contract. See Adaptation Guide Step 5 for the authoring pattern.

Equipment Library: Phases list. Click a row to open the detail view with its Parameters section

# examples/riverbend/11-phase-templates.yaml
apiVersion: procedural.dcs.io/v1alpha1
kind: PhaseTemplate
metadata:
  name: charge-reactor
spec:
  parameters:
    - name: fillPercent
      type: REAL
      required: true
      minValue: "10"
      maxValue: "100"
      unit: "%"

This is the template's contract. The MasterRecipe controller validates that every required phase parameter has a binding in the recipe step and that the referenced PhaseTemplate exists. It does NOT range-check values against minValue / maxValue today. Try to bind fillPercent: 150 and dcs recipe approve will accept it. If you need a hard range stop, layer an AlarmDefinition on the downstream tag. See Validation below.

The phase template can use fillPercent directly inside its action steps.

From the PhaseTemplate detail view, click Edit SFC → click the start_charge step → the Action ST editor references the parameter by name. The editor's context panel lists declared parameters (from Layer 3) so the reference is autocompletable.

SFC editor: step in Edit mode with the Action ST editor referencing declared parameters

The completion popup listing declared parameters, live. The clip films it in the transition-condition editor, and this layer's reference sits in the step's Action ST editor. Both run the same ST editor component, so the autocomplete behaves identically.

spec:
  actionChart:
    steps:
      - name: start_charge
        actionST: |
          WRITE('agitator.CMD', agitatorSpeed);
          WRITE('solvent_valve.CMD', fillPercent);   # ← the parameter

At runtime the SFC engine substitutes the resolved value into the ST expression, so fillPercent evaluates to 75 (or whatever the operator provided).

Layer 4 — Role binding (logical → physical)

Notice the action step writes to 'solvent_valve.CMD', with no instance name in it. The phase template references the role solvent_valve. It never names a specific instance. Roles are declared in the PhaseTemplate's cmRoles.

On the PhaseTemplate detail view, the CM Role Requirements section lists each role the template contracts for: role name, required module type, and description. Each row is a contract the Unit's role map (below) must satisfy.

Equipment Library: Phases list. Each row links to a detail view with the template's CM Role Requirements

spec:
  cmRoles:
    - role: solvent_valve
      moduleType: analog-control
      description: "Reactor solvent valve for material feed"

The role-to-instance binding lives on the Unit. The recipe step carries none of it. Each Unit declares a cmRoles map that names a specific ControlModule for each role the unit fulfills.

/system → expand the site in the left sidebar → click the Unit (fermenter-1). The CM Role Bindings section shows one row per role the unit fulfills with the bound ControlModule name on the right. Editing rewrites the map.

Unit detail: CM Role Bindings section mapping each role (agitator_drive, media_valve, …) to a concrete ControlModule instance

# examples/riverbend/10-unit-patches.yaml — Unit reactor-1
spec:
  cmRoles:
    temp_sensor:    r1-temp-sensor
    solvent_valve:  r1-solvent-valve   # ← role → instance
    outlet_valve:   r1-outlet-valve
    agitator:       r1-agitator
    # ... etc

When the runtime executes a phase on reactor-1, it resolves solvent_valve through reactor-1.spec.cmRoles to the concrete instance r1-solvent-valve. A different unit (reactor-2, reactor-7) has its own cmRoles map pointing at its own solvent valve. The same PhaseTemplate works against any of them.

The platform validates that each bound instance's moduleType (derived from its templateRef) satisfies the role's moduleType declared in the phase template. Try to point cmRoles.solvent_valve at a discrete-valve ControlModule when the phase template requires analog-control and the recipe's BindingsResolved condition goes False (blocking promotion), and a batch started anyway fails its role-contract check at instantiation. A more specific type satisfies its generic parent, but never the reverse. vfd, pid, and pid-cascade satisfy analog-control. solenoid-valve and on-off-valve satisfy discrete-valve. Inspect the full matrix with dcs recipe check-bindings <recipe>.

The single most important idea in parameter binding is that phase templates never know which physical equipment they're driving. They write to a role name, and the Unit decides which instance fulfills the role. That's what makes the same charge-reactor template work for reactor-1 in the reference plant and for reactor-7 in your plant. Both have a solvent_valve role bound to whatever the real solvent inlet is on each unit.

There is no per-step or per-recipe override of this mapping today. If you need a different binding for a specific recipe (e.g., a modulating inlet on the same unit), clone the unit. See Adaptation Guide § Step 7.

Layer 5 — Tag binding (instance → IOModule channel)

The ControlModule instance binds the template's declared inputs/outputs to physical I/O channels. The keys on the left of tagBindings are the input/output names the ControlModuleTemplate declares. The user-facing tag names are a separate vocabulary. For the analog-control template those are position_fb (input) and valve_out (output).

/system → expand the Unit → click the ControlModule (r1-solvent-valve). The Configuration section's table carries one row per bound template input/output: the declared name (with its in/out direction) on the left, the IOModule address (<iomodule>:<address>) on the right. The inputs and outputs themselves come from the referenced ControlModuleTemplate, so the list of bindable names is fixed by the template.

ControlModule form with template selected and tag bindings populated, the same layout as the instance detail view

A tag binding typed on camera: the iomodule:channel string entered at Create, and the instance polling live values seconds later. The Configuration table on the instance detail is where Debug step 5 reads the same binding back.

# examples/riverbend/07-controlmodules.yaml
apiVersion: physical.dcs.io/v1alpha1
kind: ControlModule
metadata:
  name: r1-solvent-valve
spec:
  parentName: reactor-1
  parentKind: Unit
  description: "Reactor solvent inlet valve (simulated)"
  templateRef: analog-control
  tagBindings:
    position_fb: "reactor-sim:analog.9"   # AI from the valve
    valve_out:   "reactor-sim:analog.6"   # AO to the valve

The user-facing CMD tag is backed inside the template by a REAL_CONST block (sp_cmd) whose output feeds the AO block named write_valve. When the runtime writes to r1-solvent-valve.CMD, the function-block network updates sp_cmd.OUT. That output propagates to write_valve, which writes to whatever address the valve_out output is bound to. Here that is channel 6 on the reactor-sim IOModule. A real hardware deployment would swap "reactor-sim:analog.6" for something like "wago-rack-1:holding/40006" and the same phase template would drive a Modbus device. See io/index.md for IOModule details.

Summary table

Layer What it does Where in YAML Where validated
1. Recipe parameters Operator-tunable knobs spec.parameters on MasterRecipe At apply time (schema)
2. Recipe step parameter binding Bind recipe knobs to phase inputs spec.procedure.chart.steps[*].parameterBindings At recipe approval (required params present, templateRef resolves); substitution resolves at batch instantiation
3. PhaseTemplate parameters The phase's input contract spec.parameters on PhaseTemplate At apply time (schema)
4. Role binding Logical role → physical instance spec.cmRoles on Unit (map: role → ControlModule name) At recipe reconcile (BindingsResolved condition; violations block promotion) and again at batch instantiation (bound CM's moduleType must satisfy the role's declared moduleType)
5. Tag binding Template input/output → IOModule channel spec.tagBindings on ControlModule At apply time (channel must exist on the IOModule)

Validation

Keep in mind what does and does not get enforced:

Check Enforced? Where
Required phase parameters all bound Yes MasterRecipe controller, at dcs recipe approve
Every declared cmRole mapped on the target unit Yes MasterRecipe controller (BindingsResolved), and at batch instantiation
Referenced PhaseTemplate / OperationTemplate exists Yes MasterRecipe controller, at approve
Parameter value within minValue / maxValue No Document the limit in the template; enforce via AlarmDefinition on the downstream tag
ControlModule's moduleType satisfies role moduleType Yes MasterRecipe controller (BindingsResolved condition, blocks promotion) and at batch instantiation; dcs recipe check-bindings shows the matrix
tagBindings address exists on the target IOModule Yes At ControlModule apply time

If you need hard enforcement of a parameter range at runtime, define an AlarmDefinition with conditionType: TagHigh / TagLow on the downstream tag and set exceptionAction: Stop. See Alarms & Interlocks.

How to debug a binding failure

When something doesn't work, walk the chain in order. Every step is an observe action, so each shows a UI path and a CLI fallback.

1. Did the operator's value reach the recipe step?

/hmi → Batch Execution → click the batch. The Parameters section shows the literal values the operator supplied at batch creation. Cross-check reactorChargeRate against what you expected. Accidental overrides show up here.

HMI Batch Execution: batch detail with Parameters section listing operator-supplied values

dcs get batch <name> -o yaml | grep -A 5 parameters

2. Did the recipe substitution resolve?

/hmi → Batch Execution → click the batch → Recipe SFC Preview. Its Parameter Bindings table lists every step parameter against the recipe parameter it binds to and the resolved value. Any ${reactorChargeRate} still showing in the Value column means substitution didn't run.

HMI Batch detail: the Recipe SFC Preview section on a batch that has not started, its Parameter Bindings table listing each step parameter against the recipe parameter it binds to and the substituted value

This section renders only while the ControlRecipe exists and execution has not started. Once the batch starts it is replaced by the live Procedural SFC, so for a batch already running use the CLI tab.

Debug steps 1–2 close the walk: the batch's Parameters panel, then the pre-start preview reading the substituted literal back, the value picked at creation, resolved.

dcs get controlrecipes <name> -o yaml

The ${reactorChargeRate} should be replaced with a literal. This works whatever state the batch is in, which is why it is the reliable answer for a batch that has already started.

3. Did approval pass?

/system → Recipes → Master Recipes → click the MasterRecipe. The status header shows Phase and any Validation Errors from the approval controller.

Master Recipe detail: status header with Phase badge and Validation Errors list

The approval controller catching a broken reference: the red Validation Errors section appears on the recipe detail and clears when the templateRef is fixed from the picker.

dcs get masterrecipes <name> -o yaml

Check status.phase and status.validationErrors.

4. Is the role bound to the right instance type?

Open the PhaseTemplate in /system → Equipment Library → Phases to see its CM Role Requirements (contract). Then open the Unit under the site sidebar to see its CM Role Bindings (actual). Each bound ControlModule's template type must match the role's required moduleType.

Unit detail: CM Role Bindings table. Cross-check each instance's templateRef against the PhaseTemplate's declared moduleType

dcs get phasetemplate <name> -o yaml
dcs get unit <unit> -o yaml

Cross-check each bound ControlModule's templateRef against the role's declared moduleType.

5. Is the ControlModule tag bound to a real channel?

/system → expand the Unit → click the ControlModule. The Configuration section's table shows each bound template input/output and its IOModule address. Cross-check the address against the IOModule's own detail view (site → IO Modules) to confirm the channel is declared.

ControlModule form: Tag Bindings section maps each template input/output to an IOModule channel

dcs get controlmodule r1-solvent-valve -o yaml
dcs get iomodule reactor-sim -o yaml

6. Is the channel actually wired in the IOModule?

On the IOModule detail view, the Simulation section (for sim IOModules) lists addressMap entries and the referenced SimulationPreset or inline behaviors. For real hardware, the tag live-values view under /hmi shows whether the wire is producing plausible readings. Otherwise grab a multimeter.

System → Site → Infrastructure → IOModules: list with an Online module. Click a row for the Simulation section

The IOModule detail end to end: the Online gate, the analog.N / discrete.N addressing grammar, the simulation binding, and live io-probe values ticking. Everything this step checks, on camera.

dcs get iomodule reactor-sim -o yaml

For sim, check spec.simulation.addressMap binds the channel and the referenced SimulationPreset (or any inline spec.simulation.behaviors override) drives it. Real hardware is where you grab a multimeter.

The most common bug in adapted recipes is layer 4, where the Unit's cmRoles mapping points at a ControlModule whose templateRef doesn't match what the phase template's role declaration expects. The error message usually points right at the problem. If not, walk the chain top-down.

  • Adaptation Guide — a worked end-to-end example of cloning a phase, rebinding a role, and running a batch.
  • Recipes — the recipe approval and scheduling lifecycle.
  • Mental Models — the distinction between recipe parameters (per-batch) and control-module parameters (per-instance configuration).