Field notes
What a register map doesn't say
One word in a config file decides whether a kWh total reaches a SCADA master as a counter or as a measurement. Nothing in the vendor's spreadsheet tells you which. These are notes from writing the thing that guesses, and getting it wrong four times.
The job that started this was a gateway. It had to read a three-phase meter and republish the lot over MQTT. The meter had a twelve-week lead time and the integration was due in three.
That is an ordinary situation in this line of work, and there are only two ways out of it. Write the integration blind and find out on site. Or build something that answers on the wire the way the meter will.
I already had the second thing, for unrelated reasons. silt composes Buildroot images, and one of the images it composes is an appliance: a Raspberry Pi serving a single simulated device over Modbus TCP, OPC UA, MQTT, HTTP, BACnet/IP, DNP3, IEC 61850 and CAN — eight protocol daemons, all reading one shared tag table, so no two clients can disagree about what a value is. There's a recording of it running on the front page.
The device itself is a file. About forty lines of JSON describing a simulated pump. POST a different one at port 8080 and the device changes under all eight protocols at once, with no restart and nothing rebuilt.
Which sounds like the problem is solved. It isn't, because the device that ships is a pump, and nobody's device is that pump. The question a buyer actually has is whether it will look like their meter — and their meter is described by a spreadsheet the vendor shipped, four hundred rows of register addresses.
So: write something that turns the spreadsheet into the config file. A morning's work, I thought.
The one word
Here is a row from a meter map, in the shape they usually arrive in.
Register,Description,Units,Scale,Data Type,Access
3005,Total Active Energy Import,kWh,1,UINT32,RO
Address, name, unit, scale, width, direction. Everything you need, apparently.
It does not say what the value is. And that is the one thing the
simulator cannot work out for itself, because of how eight protocols sit on top of
one tag table. Each tag declares a type — float,
int or bool — and five of the eight derive their whole
object model from it:
| type | Modbus | BACnet | DNP3 | IEC 61850 |
|---|---|---|---|---|
float |
as declared, × scale | Analog Input | Analog Input (g30) | MV |
int |
as declared, × scale | Analog Input (no integer input exists) | Counter (g20) | INS |
bool |
coil / discrete | Binary Input | Binary Input (g1) | SPS |
Read the int row twice. On DNP3 an integer becomes a
Counter, group 20. On BACnet the same integer becomes an Analog
Input, because BACnet has no integer input type at all — its object types were
designed for building automation, where everything is a temperature or a switch,
and a number that accumulates has nowhere natural to live.
So type is not a formatting hint. It is the difference between a
distribution SCADA master finding a kWh total where it expects totals, and finding
it as an analog measurement. Both work. One of them reads wrong to anybody who
knows the protocol, and you discover that in a commissioning meeting.
Only Modbus lets you state it outright, because there you pick the table yourself. The other four infer. Which means the importer has to infer too, and has to be right.
Four times wrong
I wrote the inference in a morning and then spent considerably longer finding out it was wrong. Every one of these produced a configuration that looked entirely fine.
A width is not a meaning
The first rule I wrote: if the map declares an integer type and there is no unit, it's an int.
3004,Power Factor,,1000,INT16,RO
Power factor came out as an int. Which made it a DNP3 Counter. A
ratio between zero and one, presented to a master as something that accumulates.
The mistake is obvious once it's written down. INT16 and
UINT16 describe how many bits the register has. Nearly every Modbus
point is declared one or the other, including every temperature and every voltage
in that same file. A rule keyed on it turns most of the map into counters.
The fix was deleting the rule. There is a comment where it used to be, because the next person to look at this will have the same idea I did:
// Deliberately no rule inferring int from a declared INT16/UINT16. That
// is a register width, not a meaning, and nearly every Modbus point is
// declared one or the other - so such a rule would make most unitless
// numerics ints, and every one of those arrives at a DNP3 master as a
// Counter. Semantic int comes from accumulation and nothing else.
Always[Row](TypeFloat, "default"),
Percent is a unit
To match column headers loosely — so Address, Modbus
Address, Register and Reg all mean the same thing
— I reduce a string to letters and digits before comparing it. That way Eng
Unit and engineering_units collapse together.
Now run that over a unit.
squash("%") is the empty string. So is a bare degree sign.
Percent and °C are, at a guess, the two most common units in industrial point
lists. Both of them looked to my code like no unit at all. And the
no-unit path is the one that decides a tag is a contact, by reading the name for
words like status, alarm, run,
command.
Fan Speed Command % → bool
A damper position, arriving at a BACnet client as a Binary Input. Open or shut, nothing in between.
Units have their own normaliser now — one that keeps % and
/ and digits, and folds degC, °C,
deg c and celsius onto the same key.
Seconds don't accumulate
Unit h means hours, hours accumulate, therefore h means
int. Same reasoning for s and kg.
40002,Accel Time,s,10,R/W,1,60
A drive's acceleration ramp. Not a total. A parameter you write.
Time and mass are ambiguous in a way that kWh and m³ are not. "Total Run Hours"
in h is a counter; "Accel Time" in s is a setting; the
unit alone cannot separate them. They need the name to agree, and the report now
states which way it went on every one of them — because this is the rule most
likely to be wrong on a map I have never seen.
The one that was Go's fault, slightly
I wrote the first version in Python and then moved it to Go, which is what the rest of silt is written in. The port found a bug the Python didn't have.
Go's encoding/csv has a TrimLeadingSpace option. I set
it, because stray spaces in vendor exports are universal. The documentation says,
and I am quoting it exactly:
If TrimLeadingSpace is true, leading white space in a field is ignored. This is done even if the field delimiter, Comma, is white space.
A tab is white space. And a good share of the maps I'm handed are tab-separated, because somebody selected a table in a PDF and pasted it into a text file.
So on a tab-separated map, an empty cell gets swallowed along with the tab that follows it, and every column to its right shifts left by one. The scale lands in the unit column. The last field falls off the end.
The output is still valid. It describes a different device.
I only caught it because I had kept the Python version around long enough to diff the two implementations over the same three maps. Two matched byte for byte. The tab-separated one didn't.
The one that would have shipped a lie
The four above are wrong in ways you would eventually notice. This one isn't, and it's the reason I'm writing any of this down.
A Modbus register is sixteen bits. A tag's value goes on the wire as
value × scale, so 54.3 at scale 10 reads as 543. The product has to
fit in 65535 — or 32767, if the tag can go negative.
Sometimes it doesn't fit. Then something has to give: the range, or the scale.
My first version reduced the scale. The arithmetic worked, every file validated, the tests went green, and I nearly left it there.
Think about what that does on site. The vendor's map says scale 1000. The customer's HMI divides by 1000, because that is what the manual says. My config says scale 100.
Every reading on that screen is ten times wrong, and nothing anywhere says so. Not the JSON, not the validator, not the board. The one number in that file which is a fact about the device — the thing a client divides by — and I had quietly changed it so that a range I invented would fit.
The asymmetry is the whole point, and it took me an embarrassingly long time to see it:
- If the map states the range, in min/max columns, the range is authoritative. Shrink the scale and say so loudly, because the register no longer carries what the vendor documented.
- If I guessed the range — from a coarse per-unit table, because the map had no min/max — then the scale is authoritative and my guess gets clipped instead.
The vendor's scale is a fact. My range is a guess. Guesses give way to facts. Obvious written down; not obvious at the keyboard at eleven at night with a test suite going green. There is a test named after it now, so it stays that way.
Saying what it can't do
The importer prints every decision it made to stderr, one line per tag:
columns access="Access" addr="Register" scale="Scale" type="Data Type" unit="Units"
row tag type modbus scale why
6 power_factor float input 3004 1000 default
! kept the map's scale 1000 and clipped our guessed range to 0..65.535,
which is what that scale can carry; give min/max to set it properly
7 total_active_energy_import int input 3005 1 unit 'kWh' accumulates
! declared 'UINT32' is wider than 16 bits; silt has one 16-bit register
per tag, so this will be wrong above 65535
That report is as much the deliverable as the JSON is. The thing is a clerk, not an engineer. I am not the person who knows whether "unit 'h' plus 'hours' in the name" was the right call for your plant — you are — and you can only check it if you can see it. Twenty lines to read beats four hundred rows to re-derive.
Two of the warnings are permanent limitations rather than bugs, and I would rather they were loud.
32-bit points. A real meter carries energy in a register pair and rolls over somewhere past four billion. The simulator has one 16-bit register per tag. It clips at 65535 and tells you it did. If what you are testing is 32-bit register handling then this is the wrong tool, and saying so is more useful than handing you a number that looks like a total.
Offsets. It multiplies; it does not add. Which is why the generator example in the repo uses real J1939 PGNs with real J1939 resolutions and still isn't J1939 — a J1939 temperature carries a −40 bias and there is nowhere to put it. A J1939 tool pointed at that CAN bus reads coolant temperature forty degrees low. Better in a comment than in a graph.
Of everything in there, 32-bit register pairs is the gap I would close first.
Using it
go build -o bin/silt-import-map ./tools/silt-import-map
bin/silt-import-map -device meter-1 -o meter.json my-register-map.csv
It reads the map and nothing else. No network, no dependencies outside Go's standard library. That is deliberate: the maps worth importing are usually somebody's customer's, and a tool that wanted to upload one would be useless for the job it exists to do.
Then check it before you trust it, and then put it on the board:
tools/silt-simlint/silt-simlint -v meter.json
curl -X POST --data-binary @meter.json http://BOARD:8080/config
curl -s http://BOARD:8080/tags | jq .
silt-simlint compiles the board's own validator — the same C, out of
the same package the image is built from. There is deliberately no second copy of
the rules, because a linter that disagreed with the device would be worse than no
linter at all, for being trusted. It caught two mistakes in my own hand-written
examples while I was writing them, including a nice one: the validator refuses
"unit": "" outright, and the error doesn't mention that empty is the
problem. Every dimensionless ratio in every map hits that.
What I'd still change
An honest list, since most of the above is me being wrong in public and it would be strange to stop now.
The importer cannot check its own output — the validator is C, inside the image's package — so a shell script imports every example map and lints the result on every commit. Without that the two drift apart and nobody finds out until a POST fails on somebody else's bench.
BACnet, DNP3 and 61850 are read-only in the appliance. Writable tags appear there but are only settable over Modbus, OPC UA, MQTT and HTTP. For a utility that is a real gap, because DNP3 control with select-before-operate is often the thing you most need to test. It's deferred rather than dismissed: doing it properly means the whole control model, CROB timing, and a command handler that is right about every variation.
And the inference will be wrong on maps I have not seen. It is, in the end, a pile of heuristics about how people name things. If you run it on yours and the report says something daft, I would like to see the report — not the register values, just the report. That is how the rules get better.
I'm an independent embedded consultant: custom Linux images with Buildroot, board bring-up, industrial protocols, and licence compliance you can hand to a customer. If any of the above sounds like your week, get in touch.
The tool and the examples are in
github.com/vinodhalaharvi/silt
under tools/silt-import-map and examples/.