Harbor Fresh Foods runs five refrigerated units. Truck 11, Truck 12 and Truck 14 carry a CAN-bus device. It reads the tank level, the odometer and the engine-hours meter from the vehicle bus. Van 21 and Van 22 carry an analogue fuel probe on the device's analogue input, plus an ignition wire and GPS.
The fleet manager wants one monthly fuel report with litres, kilometres and engine hours per unit. She compares litres per engine hour across the fleet. A naive template reads the engine-hours meter, and both vans show a dash. Or it reads ignition time, and the trucks ignore the meter the workshop services against. Either way, the reader cannot tell which number came from which sensor.
Start with what does not need a conditional source: the litres. On both, the dealer sets up a fuel level sensor, sensor type FUEL. The CAN-bus and analogue difference lives on the device. It decides which parameter the sensor reads and, for the probe, which calibration table converts it. The fuel calculators then read each unit's effective fuel sensor. That is the one with the use-in-reports flag, otherwise the first FUEL sensor. See Set up a fuel sensor and Fuel detection.
The device's Conditional tab is a different tool. It picks a value per message inside one sensor field. It cannot make a report read another sensor.
What differs per unit here is the denominator. The trucks have an ENGINE_HOURS meter and an ODOMETER; the vans have IGNITION and GPS. The fuel usage calculator's own metrics.engine_s counts ignition-on time for every unit. Its metrics.move_distance_km takes an absolute odometer reading where there is one and GPS otherwise, and it never says which.
A conditional column fixes both. It holds an ordered list of candidate measurements, called variants. For each unit, the engine picks the first candidate whose sensor requirement that unit's sensor types satisfy. Resolution looks at sensor presence, not at data, and happens once per report run. The trade-off is deliberate: a template that switched sources on data would answer differently on different days.
The builder offers two ready-made chains at the top of the Add column menu. Distance (best available) reads ODOMETER, then RELATIVE_ODOMETER, then GPS trip distance. Engine hours (best available) reads the ENGINE_HOURS counter, then ENGINE sessions, then ignition sessions. The catalog drops any candidate your deployment does not calculate. For building one by hand, see Add calculated and best-available columns.
Harbor Fresh Foods copies the fuel summary table and replaces two columns with chains. This is the table as the engine accepts it:
{
"id": "fuel_by_unit",
"label": "Fuel by unit",
"source": "interval:fuel_usage",
"group_by": ["unit"],
"columns": [
{ "key": "unit_id", "resolve": "unit_name" },
{ "key": "metrics.total_consumption_l", "agg": "sum", "uom": "volume" },
{
"key": "engine_hours",
"label": "Engine hours",
"agg": "sum",
"uom": "duration",
"fallback": "auto",
"variants": [
{ "source": "interval:engine_hours_counter", "key": "metrics.delta", "expr": "metrics.delta * 3600" },
{ "source": "interval:ignition_session", "key": "duration_s" }
]
},
{
"key": "distance",
"label": "Distance",
"agg": "sum",
"uom": "distance",
"fallback": "auto",
"variants": [
{ "source": "interval:mileage", "key": "metrics.delta" },
{ "source": "interval:trip", "key": "metrics.distance_km" }
]
}
]
}
The rules the engine checks on save:
key and label, because no single catalog field fits every unit.source and a key, and the key must exist on that source.expr converts one candidate into the column's uom. A column carries either expr or variants, never both.requires lists sensor types and overrides the source's own requirement. Leave it out in the normal case.fallback is auto (the default), strict or observed. In the builder, auto is Use the next source it can answer and strict is Show a dash — never substitute another source. Under strict, a unit without the first source gets a dash.agg on the chained column.The interval:<kind> names come from your deployment's calculator configuration. Pick them in the builder rather than typing them. For the rest of the builder, see the Report template builder reference.
Inside one run the chains partition the units. Each unit resolves to exactly one candidate, so a truck with both an odometer and trips never counts its kilometres twice. Columns that are not chained keep reading only the table's own kinds.
The engine returns the resolution with each table, one entry per conditional column. Each entry carries unit ids as keys, and the service adds the unit names:
{
"alias": "sum_engine_hours",
"units": {
"<truck-11-id>": "interval:engine_hours_counter",
"<truck-12-id>": "interval:engine_hours_counter",
"<truck-14-id>": "interval:engine_hours_counter",
"<van-21-id>": "interval:ignition_session",
"<van-22-id>": "interval:ignition_session"
},
"unit_names": { "<truck-11-id>": "Truck 11", "<van-21-id>": "Van 21" }
}
The unit_names map above shows two entries of five. A unit no candidate fits goes into unresolved, with the sensors it lacks in missing_sensors. When every unit lands on one source, uniform names it. A mixed table also carries variant_summary, a counts-per-source line for the AI assistant.
On screen, the disclosure is per unit, never per cell:
Opening a saved report reuses the sensor map frozen when it ran. Last quarter's report keeps saying which sensor each figure came from, even after someone fits a new meter. For how sources, sensors and totals fit together, see Where report data comes from.
metrics.delta is in hours, and a duration column is in seconds. The validator only catches candidates that declare different units, and metrics.delta declares none. Fix: give the counter candidate "expr": "metrics.delta * 3600". Convert every candidate into the column's unit.ENGINE_HOURS sensor that reports nothing, and presence wins. Fix: remove the dead sensor from the device. Or set "fallback": "observed" in the template JSON, which skips a candidate that wrote no rows for that unit in the range. The builder does not offer observed, and an engine that predates it refuses the template.ENGINE sessions ahead of ignition sessions. Where the engine input shares the ignition circuit, that candidate measures the same thing under another name. Fix: remove it, as the shipped Engine hours template does.sum(metrics.total_consumption_l) / engine_hours fails, because an expr reads catalog fields of the table's sources, not other columns. Fix: work out the ratio outside the report.