Read the Dataset Before You Touch the Label
Here is the symptom. The operator scans the first part of an 8-part stack, and its serial shows in the label bound to row 1. The operator scans the second part. The first serial jumps to row 2. On the third scan it moves to row 3. The labels bound by row index now show the wrong part, or zero.
The query behind it is a named query with eight SELECT COALESCE(SUM(serial), 0) FROM parts WHERE ... AND stack_pos = n statements joined with UNION. It is bound to a view custom property, and each label reads a fixed cell such as Dataset[1,0].
Start here. Open the custom property in the Perspective property editor, or run the named query in the Designer test pane with real :layer, :layer_pos and :pallet values. Count the rows before and after each scan.
| What you see in the dataset | Cause | Next check |
|---|---|---|
| Fewer than 8 rows while the stack is partly scanned |
UNION removes duplicate rows, so all the zero rows from empty positions collapse into one |
Check 1 |
| 8 rows, but serials sit in a different order than scan order | No ORDER BY, so the database returns rows in whatever order its plan produces |
Check 2 |
A single 0 row at the top, with real serials below it |
Duplicates are removed by a sort, which puts 0 first and pushes real values down | Checks 1 and 2 |
| No column that says which position a row belongs to | The labels depend on row index, not on data | Check 3 |
That is not a Perspective binding fault. The label is doing exactly what you told it to do. The dataset shape is changing under it.
Check 1: Is the Query Using UNION or UNION ALL?
UNION is a set operation. It returns distinct rows only. Before the stack is full, every empty position returns the same single value, 0. Seven identical 0 rows become one row. The dataset shrinks, and every row index shifts.
Many database engines remove duplicates by sorting or hashing the combined result. A sort puts 0 ahead of any real serial, and the real serials follow in value order, not in stack_pos order. On each new scan, one zero row turns into a real value, the row count changes, and existing serials slide down one index. That is the "pushed down" behavior.
- If the query uses
UNION: switch toUNION ALL. That stops the collapse, but you still have no guaranteed order. Go to Check 2. - If it already uses
UNION ALLand rows still move: the order is the problem. Go to Check 2.
Check 2: Does the Result Carry Its Own Key and an ORDER BY?
SQL does not guarantee row order without ORDER BY. Not for UNION ALL, not for a plain SELECT. Code that works today can reorder after an index change, a statistics update, or a database upgrade.
Look at the columns. If the dataset has only a serial column, no ordering is possible because nothing in the data says which row belongs to position 1.
Minimum fix, if you have to keep the eight-select structure: add the position as a literal column and sort on it.
SELECT 1 AS stack_pos, COALESCE(SUM(serial), 0) AS serial
FROM parts
WHERE layer = :layer AND layer_pos = :layer_pos
AND pallet = :pallet AND stack_pos = 1
UNION ALL
SELECT 2, COALESCE(SUM(serial), 0)
FROM parts
WHERE layer = :layer AND layer_pos = :layer_pos
AND pallet = :pallet AND stack_pos = 2
-- ... repeat through 8 ...
ORDER BY stack_pos
Each branch always returns exactly one row, because an aggregate with no GROUP BY returns one row even when nothing matches. With UNION ALL and ORDER BY, you get eight rows in position order. It works, but it runs eight scans of parts per poll. Go to Check 3 for the clean version.
Check 3: Do Empty Positions Return a Row on Their Own?
The clean design uses one query that returns one row per position, whether a part exists or not. To do that, you need a source of all eight positions. The source is a small table, for example stack_positions, with one column holding 1 through 8. Left-join parts to it.
SELECT sp.stack_pos,
COALESCE(SUM(p.serial), 0) AS serial
FROM stack_positions sp
LEFT JOIN parts p
ON p.stack_pos = sp.stack_pos
AND p.layer = :layer
AND p.layer_pos = :layer_pos
AND p.pallet = :pallet
GROUP BY sp.stack_pos
ORDER BY sp.stack_pos
Put the :layer, :layer_pos and :pallet filters in the ON clause. If you move them to WHERE, they discard the null rows from unmatched positions, and the left join acts as an inner join. The empty positions disappear again, and so does your fixed row count.
Then stop reading by row index. Bind each label to the value, keyed by position, using lookup():
lookup({view.custom.stackData}, 3, 0, "stack_pos", "serial")
Arguments: the dataset, the value to find (position 3), the value to return if there is no match (0), the column to search, and the column to return. Now the label shows position 3 even if the row order changes, rows are missing, or a ninth position is added later. This is the resolving branch.
Apply the Fix and Verify It
- Create the
stack_positionstable and insert values 1 through 8. Match the data type ofparts.stack_posso the join does not need an implicit cast. - Replace the named query body with the left-join query above. Keep the three parameters and their types.
- In the Designer test pane, run it against a pallet that has no scans. Expect exactly 8 rows,
stack_pos1 through 8, allserial = 0. - Run it against a partly scanned pallet. Expect 8 rows, with serials only in the positions that were scanned.
- Bind the named query to one view custom property, for example
view.custom.stackData, with the poll rate you already use. - Replace every per-label query binding with an expression binding that uses
lookup()and that label's position number. - Delete the old per-label named query bindings. If you leave them in place, they keep polling.
Verify on the floor:
- Scan parts out of order (for example 5, then 1, then 8). Each serial must appear under its own label and stay there.
- Delete or re-position a part using the screen's existing functions. Only the affected labels change on the next poll.
- Check the gateway's database connection status page. Active connections and query throughput should drop.
Load, derived from the numbers given: 25-30 screens with about 10 one-second queries each is 250-300 queries per second when every screen is open. One query per screen brings that to 25-30 per second, a tenfold reduction before you change any poll rate.
Pitfalls That Waste Time
-
Adding
ORDER BYwithout a key column. You can only sort on data. Sorting by serial does not give position order. -
Swapping
UNIONforUNION ALLand stopping. The collapse stops, but the order still is not guaranteed. It will break again later. -
SUM(serial)hides double scans. If the same position is scanned twice, the label shows the sum of two serials, which is a meaningless number. If one part per position is the rule, useMAX, or returnCOUNT(*)as a second column and flag any count above 1. -
Filters in
WHEREon a left join. This is covered in Check 3, and it is the most common reason the fixed version still returns fewer than 8 rows. -
Row-index bindings elsewhere. Search the view for other
[row,col]references to the same property and convert them tolookup(). - Rewriting everything into session memory first. Buffering scans in custom properties and writing at the end of the stack is a valid design. But when the screen also deletes and re-positions parts directly in the database, the keyed query is the smaller change.
FAQ
What happens if I use UNION instead of UNION ALL in an Ignition named query?
UNION removes duplicate rows, so identical values such as the 0 from several empty positions collapse into one row. The row count changes as data arrives, and any binding that reads a fixed row index shows the wrong value.
What happens if a named query has no ORDER BY?
The database returns rows in whatever order its execution plan produces. That order can change between runs, so add a key column and ORDER BY it, and read values with lookup() rather than by row index.
What happens if lookup() finds no matching stack_pos?
It returns the no-match argument, for example the 0 in lookup({view.custom.stackData}, 3, 0, "stack_pos", "serial"). The label shows that default value instead of throwing an error.
How do I return a row for every stack position even when no part is scanned?
Left-join parts to a table holding positions 1-8, keep the pallet and layer filters in the ON clause, and GROUP BY the position. COALESCE turns the unmatched positions into 0.
What happens if the dataset still shows fewer than 8 rows after the fix?
Check whether the filters moved into WHERE, and whether stack_positions actually holds 1-8 in the same data type as parts.stack_pos. If the query returns 8 rows in the Designer but not at runtime, and the gateway database connection status or logs show errors or timeouts under load, collect the query text, the gateway logs and the connection status, and open a case with Inductive Automation support.