Which fixes do engineers try first, and why do they fail?
The setup is simple. An integer selector tag holds 0, 1 or 2. Its value change event script picks a set of tuned P, I and D gains, reads them with system.tag.readBlocking(), and writes them into the live PGain, IGain and DGain tags with system.tag.writeBlocking(). The PLC PID block is the final consumer. It uses those gains to drive the valve or motor.
The symptom is equally simple. With the if/elif block commented out and one gain path hardcoded, the read and write work, and ScriptValue shows the right selector value. With the if/elif block active, nothing reaches the gain tags. Most engineers then spend time on fixes that cannot work:
-
Reformatting the indentation and
elifsyntax. Theif/elifstructure is valid Python. Moving colons and re-indenting changes nothing, because the problem is the data being compared, not the grammar. - Testing the logic in the Vision client or Designer script console. Tag event scripts do not run in Vision. They run in the gateway. A console test calls the code with a plain integer you typed in, so it passes. The gateway calls it with a different object type, so it fails.
- Hardcoding the paths to "prove" the tag reads work. This proves the read/write half of the chain is healthy. That is useful, but it points attention at the wrong half.
- Re-tuning the PID because the loop is not responding to the selector. Tuning does not fix wiring. If the new gains never reach the controller, no gain value on the HMI changes loop behavior.
What does the gateway actually pass into valueChanged?
The signature is:
def valueChanged(tag, tagPath, previousValue, currentValue, initialChange, missedEvents):
currentValue and previousValue are not raw numbers. Each one is a qualified value object that bundles three things:
- the value
- a quality code
- a timestamp
The raw number lives in currentValue.value. The quality lives in currentValue.quality, and the timestamp in currentValue.timestamp. Inductive Automation documents these objects on the Scripting Object Reference page of the Ignition User Manual.
Follow the original script from that fact:
-
value = currentValueassigns the whole qualified value object, not the integer. -
value == 0,value == 1andvalue == 2each compare an object to an integer. All three evaluateFalse. - No branch runs, so
gainpathsis never assigned. -
system.tag.readBlocking(gainpaths)references an unassigned local variable. Python raises an unbound-local error, and the script aborts beforewriteBlocking().
The hardcoded test succeeded because it bypassed step 2 entirely. The fix is one attribute:
value = currentValue.value
Why do the gain writes work even though readBlocking returns objects too?
system.tag.readBlocking() returns a list of qualified values, not raw numbers. In the original script, gainvalues[0] through gainvalues[2] are therefore qualified value objects. They pass straight into the write list.
This works because system.tag.writeBlocking() accepts qualified values as well as raw values. It is also a trap, because the same list used anywhere else behaves differently:
- Arithmetic on a qualified value fails.
- Comparisons against numbers silently return
False, which is the same failure mode as the selector bug. - Logging one prints the object representation instead of the number.
Extract .value as soon as you read, and keep raw numbers in your working variables. There is a second reason to unpack on read. If a source gain tag has bad quality, its value may be null. Passing that object straight through can write a null or bad-quality value into a live PID gain. Check quality before writing (see the hardened script below).
Where does each signal come from, and what does a wrong value look like?
Look at the trend first. Before editing code, confirm what each tag in the chain holds and where it is written from.
| Signal | Source | Wrong-value symptom | Likely cause |
|---|---|---|---|
Selector tag (0/1/2) |
Operator entry or Vision control writing the tag | Changes on screen, but gains never move | Script compares the qualified value object instead of .value
|
ScriptValue |
Written by the tag event script | Never updates, or shows a stale value | Script aborts before writeBlocking(); path spelling or case mismatch (source used Scriptvalue in one place and ScriptValue in another) |
PGainTuned* / IGainTuned* / DGainTuned*
|
Stored recipe tags | Read returns bad quality or null | Path does not resolve under [~], or the tag is missing or misnamed |
PGain / IGain / DGain
|
Written by the script; consumed by the PLC PID block | Unchanged after a selector change | Script failed upstream, or the write was rejected (check the returned quality codes) |
| Same gain tags | Same | Jump to the "Fast" set on any unexpected selector value | Clamping logic maps out-of-range values to the nearest valid index |
| PID output (final element) | PLC | Bumps when gains change | Controller algorithm form and integral handling on a gain change; a PLC-side issue, not a script issue |
What happens when the selector holds a value outside 0-2?
Even with .value fixed, the original structure fails for any selector value other than 0, 1 or 2. There is no else branch, so gainpaths is undefined and the script throws the same unbound-local error.
Selector tags pick up stray values more often than people expect:
- a numeric entry field without limits
- a default value on tag creation
- a PLC write
- a null while the tag's quality is bad
You have two defensible policies. Choose one deliberately, because the choice lands on a live control loop:
| Policy | Behavior on out-of-range input | When to use |
|---|---|---|
| Clamp | A value below 0 maps to Slow; a value above 2 maps to Fast | Exercise or test benches, where any valid gain set is acceptable |
| Reject | Log the value, leave the current gains untouched, and optionally write the selector back to its previous value | Production loops, where an unintended jump to the most aggressive tuning can upset the process |
For a PID loop, rejecting is the safer default. A typo should not silently apply your fastest gains.
How do I rewrite the script so it cannot fail silently?
A compact approach builds the paths from a list of tuning names instead of an if/elif ladder. The tag naming convention (PGain + TunedSlow = PGainTunedSlow) makes this possible. It also removes the undefined-variable failure, because an index into a list either resolves or is caught.
The clamp version, with syntax corrected (the commonly shared form of this pattern is missing a closing parenthesis on max() and a closing parenthesis on the gain tuple):
def valueChanged(tag, tagPath, previousValue, currentValue, initialChange, missedEvents):
gainTypes = ['TunedSlow', 'TunedMed', 'TunedFast']
# Clamp the selector between 0 and len(gainTypes)-1
value = max(0, min(currentValue.value, len(gainTypes) - 1))
gainPaths = ['[~]' + gain + gainTypes[value] for gain in ('PGain', 'IGain', 'DGain')]
writeValues = [value] + [qv.value for qv in system.tag.readBlocking(gainPaths)]
system.tag.writeBlocking(['[~]ScriptValue', '[~]PGain', '[~]IGain', '[~]DGain'], writeValues)
The hardened reject version adds three guards:
- a quality check on the selector
- a range check
- a quality check on every gain read before anything is written
It also logs to the gateway, which is where this script runs.
def valueChanged(tag, tagPath, previousValue, currentValue, initialChange, missedEvents):
log = system.util.getLogger('GainSelector')
gainTypes = ['TunedSlow', 'TunedMed', 'TunedFast']
if not currentValue.quality.isGood() or currentValue.value is None:
log.warn('Selector quality bad at %s; gains unchanged' % tagPath)
return
value = int(currentValue.value)
if value < 0 or value >= len(gainTypes):
log.warn('Selector %s out of range; gains unchanged' % value)
return
gainPaths = ['[~]' + g + gainTypes[value] for g in ('PGain', 'IGain', 'DGain')]
reads = system.tag.readBlocking(gainPaths)
for path, qv in zip(gainPaths, reads):
if not qv.quality.isGood():
log.warn('Bad read on %s; gains unchanged' % path)
return
writeValues = [value] + [qv.value for qv in reads]
results = system.tag.writeBlocking(['[~]ScriptValue', '[~]PGain', '[~]IGain', '[~]DGain'], writeValues)
for q in results:
if not q.isGood():
log.warn('Gain write rejected: %s' % q)
Settle these decisions before deploying:
-
initialChange: the event also fires when the tag first initializes, for example on gateway start or after the tag is edited and saved. Decide whether reapplying the selected gain set at that moment is acceptable. If it is not, return early wheninitialChangeisTrue. -
[~]path resolution: the relative prefix resolves against the tag's own context rather than an absolute provider path. Confirm that[~]PGainTunedSlowresolves to the tag you expect by checking the read quality in the log. Fix the path structure if it does not, before trusting any write. -
Blocking calls on the gateway:
readBlockingandwriteBlockinghold the tag event thread until they complete. Against memory tags this is negligible. Against slow device tags, keep the script short and do not chain additional blocking work into it.
How do I verify the fix end to end?
Measure before adjusting. Verify each link of the chain in order, from selector to final element:
- Open the gateway log viewer, not the Vision client console. Filter on the logger name used in the script.
- Write
0to the selector. Confirm thatScriptValuereads0and thatPGain,IGainandDGainmatch the*TunedSlowtags exactly. - Repeat for
1and2, checking against the*TunedMedand*TunedFastsets. - Write an out-of-range value such as
5. With the reject version, the gains must stay unchanged and a warning must appear in the log. With the clamp version, the Fast set must apply. Confirm that this matches the policy you chose. - Temporarily rename one source gain tag, then change the selector. The script must log a bad read and leave all three live gains alone rather than writing a partial set.
- Watch the PLC PID block's gain registers online. The values the controller uses must match the Ignition tags, which confirms the device tags are actually writing through to the controller.
- Trend the controller output across a gain change with the loop in automatic. A step in output at the instant of the change points to the controller's algorithm and integral handling, not to the script.
FAQ
Why does my Ignition tag change script if statement never evaluate true?
currentValue is a qualified value object, not the raw tag value, so currentValue == 0 is always False. Compare currentValue.value instead.
Why does my valueChanged script work in the script console but not on the tag?
Tag event scripts run in the gateway, not in Vision or the Designer console. When you test in the console, you usually pass a plain number. The gateway passes a qualified value with value, quality and timestamp, so test against that object and read errors in the gateway log.
Why does system.tag.readBlocking return objects instead of numbers?
It returns a list of qualified values so you can check each read's quality. Use [qv.value for qv in system.tag.readBlocking(paths)] to get raw values. writeBlocking() accepts either form.
Why does my tag event script throw a referenced-before-assignment error?
A variable assigned only inside if/elif branches does not exist when no branch matches. That happens when the selector is outside the handled values, or when you compared the qualified value object instead of .value. Add range and quality guards, or use list indexing that validates the selector before building paths.
How do I stop out-of-range selector values from changing PID gains in Ignition?
Check 0 <= value < len(gainTypes) and return early with a gateway log warning when it fails. Also skip the write if the selector or any gain read has bad quality. If the gateway log shows the script completing with good write results but the PLC registers still do not change, the problem is in the device connection or controller configuration. Take the gateway logs and tag diagnostics to Inductive Automation support, or to the PLC vendor's support for controller-side behavior.