Specification v0.4
This is the rendered normative specification, synced from moddef/spec. The Markdown source is the source of truth.
Status: Draft
Version: 0.4
Primary file extensions: .moddef, .moddef.json, .moddef.yaml
Canonical schema: Protobuf
Primary domain: Modbus register map and semantic device model
description
v0.4 (2026-06-04) adds the encoding constructs that v0.3 turned out to be missing once it was checked against 136 real device Modbus specifications (energy meters, solar inverters, BESS, EV chargers, HVAC heat pumps). The new constructs are register-referenced scale factors, unavailable/sentinel values, discovered (SunSpec model-chain) addressing, conditional point presence, register-field structs, flag sets, date/time values, repeating arrays with a stride, a few extra storage widths, and distinct write encodings. See §37 Revision History for the full list and the reasons behind it (
research/MODDEF-GAP-ANALYSIS.md).
1. Overview
ModDef is a portable file format for describing Modbus devices, their registers, register encodings, data types, semantic meanings, and generated client APIs.
A ModDef file describes both:
- The physical Modbus mapping, where a value is located and how it is encoded.
- The semantic meaning, what the value represents, such as active power import, frequency, state of charge, or voltage measured between L1 and neutral.
This separation allows tooling to generate typed clients, validate register maps, expose standard methods, and reuse shared definitions across manufacturers and standards.
2. Goals
ModDef is designed to:
- Provide a machine-readable replacement for vendor PDF and spreadsheet register maps.
- Support binary, JSON, and YAML encodings with equivalent semantics.
- Allow strong validation through a linter and compiler.
- Support code generation for multiple programming languages.
- Support reusable standard libraries, including SunSpec mappings and ModDef core measurands.
- Make semantic data access possible through generated methods such as
get_active_power_import()orget_voltage(phase="L1-N"). - Support low-power and embedded use cases via a compact binary representation.
- Preserve enough detail to avoid ambiguity around endianness, scaling, units, access permissions, and register address interpretation.
3. Non-goals
ModDef does not:
- Define the Modbus protocol itself.
- Define how devices are discovered on the network.
- Require devices to expose self-description over Modbus.
- Replace SunSpec, OCPP, IEC, or other domain standards.
- Guarantee that two vendors use the same physical register addresses.
- Require end users to know or use Protobuf directly.
- Describe non-Modbus transports or control planes. ModDef is a Modbus description format. Devices whose only machine interface is OCPP, REST, CAN, DNP3, eBUS/EMS, BACnet objects, or a proprietary serial frame are out of scope and cannot be fully modeled by ModDef. (Validation against 136 real device documents found ~28 such devices; see §37.) A future transport-neutral sibling format may address them; v1 does not.
4. File Types
ModDef has three equivalent representations.
4.1 Binary Representation
Extension:
.moddef
The .moddef file is the binary representation of a ModDef document.
Recommended encoding:
- Protobuf binary encoding of
ModDefDocument. - Optional wrapper header may be introduced later for magic bytes, schema version, compression, and signatures.
- The first stable release should avoid compression by default to simplify embedded implementations.
4.2 JSON Representation
Extension:
.moddef.json
The .moddef.json file is the JSON representation of the same document.
It is intended for:
- Programmatic tooling.
- Web services.
- Git diffs where YAML is not preferred.
- Schema validation.
4.3 YAML Representation
Extension:
.moddef.yaml
The .moddef.yaml file is the human-editable representation.
It is intended for:
- Handwritten device profiles.
- Documentation examples.
- Review in pull requests.
- Manufacturer authored files.
YAML comments are allowed but must not carry semantic meaning.
4.4 Equivalence Requirement
The following conversions must be lossless:
.moddef.yaml -> .moddef.json -> .moddef
.moddef -> .moddef.json -> .moddef.yaml
.moddef.json -> .moddef -> .moddef.yaml
Canonical tooling must preserve all semantic information.
5. Terminology
5.1 Document
A complete ModDef file.
5.2 Device Profile
A definition for a specific device, product family, or reusable device class.
5.3 Point
A user-visible data point exposed by a device.
Examples:
- Total active power import
- Frequency
- L1-N voltage
- Battery state of charge
- Device status
- Alarm bit field
5.4 Mapping
The physical Modbus location and encoding for a point.
5.5 Register
A 16-bit Modbus register in either the input register or holding register address space.
5.6 Word
A 16-bit quantity. In Modbus register spaces, one register equals one word.
5.7 Measurand
The semantic meaning of a point.
Examples:
active_powerenergy_active_registervoltagefrequencystate_of_charge
A measurand may be qualified by direction, phase, reference, location, aggregation, and context.
6. Document Structure
A ModDef document contains:
- Metadata
- Imports
- Type definitions
- Measurand definitions
- Device profiles
- Register blocks
- Points
- Optional variants
- Optional standard library references
Conceptual structure:
doc_id: example.energy-meter
name: Example Energy Meter
version: 0.1.0
imports:
- uri: moddef:stdlib:measurands:1.0.0
alias: meas
enums:
- type_id: device_status
values:
- value: 0
name: ok
- value: 1
name: warning
- value: 2
name: fault
devices:
- device_id: example-meter
vendor: Example Corp
model: EM-1000
blocks:
- block_id: electrical-measurements
space: HOLDING_REGISTER
start_offset: 0
length_words: 100
points:
- point_id: frequency
name: Grid Frequency
measurand:
measurand_id: frequency
mapping:
space: HOLDING_REGISTER
offset: 10
length_words: 1
storage_type: U16
value_type:
primitive: DECIMAL
transform:
scale:
numerator: 1
denominator: 100
unit: Hz
access: READ_ONLY
7. Address Spaces
ModDef explicitly supports the four Modbus address spaces.
Address space Modbus concept Typical access
COIL 1-bit read/write values read/write
DISCRETE_INPUT 1-bit read-only values read-only
INPUT_REGISTER 16-bit read-only registers read-only
HOLDING_REGISTER 16-bit read/write registers read/write
7.1 Addressing Rule
ModDef stores Modbus addresses as 0-based offsets within each address space.
This avoids ambiguity between:
- Protocol-level offsets.
- Vendor documentation addresses.
- Human notations such as
40001.
Example:
Vendor notation ModDef space ModDef offset
40001 HOLDING_REGISTER 0 40083 HOLDING_REGISTER 82 30001 INPUT_REGISTER 0
Tools may optionally accept human notation, but the canonical document must use 0-based offsets.
7.2 Function Codes
The ModDef address space implies compatible function codes.
Space Read functions Write functions
COIL 01 05, 15 DISCRETE_INPUT 02 none INPUT_REGISTER 04 none HOLDING_REGISTER 03 06, 16
A point may optionally constrain supported function codes if a device has non-standard behavior.
7.3 Dynamic and Discovered Addressing (v0.4)
Some devices do not place data at fixed offsets. The most important case is
SunSpec, where a device exposes a chain of models discovered at runtime:
a well-known anchor register holds the marker SunS (0x53756E53), followed by
repeating (model_id, length) headers that must be walked to learn where each
model (and therefore each point) actually lives. Addresses also shift with
device composition (e.g. Fronius moves blocks depending on float-vs-integer
model selection and whether a meter is present).
A register block may declare a discovery anchor so that the offsets of its points are resolved relative to a model located at run time, rather than being absolute:
blocks:
- block_id: sunspec-inverter
space: HOLDING_REGISTER
discovery:
kind: SUNSPEC # walk SunS marker + (model_id,length) chain
anchor_candidates: [0, 40000, 50000]
model_id: 103 # resolve this block to SunSpec model 103
points:
- point_id: ac_power
mapping:
model_relative_offset: 14 # offset within the resolved model
storage_type: S16
value_type: { primitive: DECIMAL }
Rules:
- When
discovery.kindisSUNSPEC, point offsets are interpreted asmodel_relative_offsetwithin the resolved model; the absolute address is computed by the codec after the model chain is walked. model_relative_offsetis measured from the model's ID register: offset 0 is the model id, offset 1 the model length, and data begins at offset 2. This matches the canonical SunSpec model tables (e.g. model 103Wat offset 14, as in the example above).anchor_candidateslists the register addresses to probe for theSunSmarker (commonly 0, 40000, or 50000).- A block with no
discoveryuses absolute offsets as before (default).
See also §17.1 (conditional point presence), which handles points that exist only in some device compositions.
8. Data Types
ModDef separates logical type from physical storage type.
This is necessary because a value may be logically a decimal quantity but physically stored as an integer with scaling.
8.1 Logical Types
Logical types describe what the value means to users and generated clients.
Supported logical types:
BOOLINT32UINT32INT64UINT64FLOAT32FLOAT64DECIMALSTRINGBYTESENUMSTRUCTARRAYDATETIME(v0.4): a calendar date/time or duration; see §8.5.FLAGS(v0.4): a set of independently-true boolean flags; see §13.2.
8.2 Storage Types
Storage types describe how values are physically encoded in Modbus.
Supported storage types:
BITU16S16U32S32U64S64IEEE754_F32IEEE754_F64STRING_ASCIISTRING_UTF8BYTES_RAWBCDFIXED_POINTCOMPOSEDU24,U48(v0.4): 24-bit and 48-bit unsigned integers.S48(v0.4): 48-bit signed (two's complement) integer.
These odd widths appear in real maps (e.g. 48-bit energy counters). Signed
storage types are two's complement by default; sign_magnitude: true may be
set on a mapping where a device uses sign-magnitude instead.
8.3 Required Typing Rule
Each point must define:
value_typestorage_type
This prevents ambiguity.
Example:
storage_type: U16
value_type:
primitive: DECIMAL
transform:
scale:
numerator: 1
denominator: 10
This means the device stores an unsigned 16-bit integer, but the exposed value is decimal.
8.4 Unavailable / Sentinel Values (v0.4)
Almost every real register map reserves one or more raw values that mean "not implemented", "no data", or "over-range" rather than a real reading. Common conventions:
- SunSpec:
0x8000forint16,0x80000000forint32,0xFFFF/0xFFFFFFFFfor unsigned "not accumulated", and a NaN for floats. - ABB:
0x7FFF…(max positive); Carlo Gavazzi:0x7FFF→ displayEEEE.
ModDef v0.3 had no way to declare these, so a reader could not distinguish a real value from "unavailable". v0.4 adds an optional, per-point sentinel set:
storage_type: S16
value_type: { primitive: DECIMAL }
na_values:
- raw: 0x8000 # decoded result is "unavailable", not a number
Rules:
na_valueslists raw register patterns (after width/endianness decode, before scaling) that decode to a typed "unavailable" result.- A default sentinel set MAY be supplied by a
storage_typeprofile in a standard library (e.g. the SunSpec stdlib package) and inherited by points. - Generated clients expose unavailable readings as a typed absence
(e.g.
None/nil/Optional.empty), never as the raw sentinel.
8.5 Date and Time Values (v0.4)
A DATETIME logical type carries a calendar instant or duration. Devices
encode these several ways; the encoding is declared explicitly:
value_type: { primitive: DATETIME }
datetime:
encoding: EPOCH_S # seconds since 1970-01-01T00:00:00Z
width: U32 # or U64
Supported encoding values:
EPOCH_S/EPOCH_MS: unsigned seconds/milliseconds since the Unix epoch, stored in aU32/U64.PACKED_BCD_DATETIME: BCD-packedYYYYMMDDhhmmssacross registers.SPLIT_FIELDS: separate year/month/day/hour/minute/second sub-fields, described with a register-field struct (§13.1).
DATETIME values are not measurands; they are typically timestamps on log
entries, RTC registers, or energy-counter snapshots.
9. Endianness and Word Order
Modbus registers are 16-bit words. Multi-word values must define how words and bytes are ordered.
9.1 Byte Order
Byte order describes byte order within each 16-bit word.
Values:
BIG_ENDIANLITTLE_ENDIAN
9.2 Word Order
Word order describes ordering of 16-bit words in multi-word values.
Values:
WORD_BIG_ENDIANWORD_LITTLE_ENDIAN
9.3 Defaults and Warnings
For single 16-bit register values, byte order is usually not meaningful beyond Modbus wire format.
For multi-word values, tools must warn if either byte order or word order is omitted.
Recommended linter rule:
MDW001: Multi-word value does not explicitly specify byte_order and word_order.
10. Scaling, Offset, and Units
10.1 Rational Scaling
Scale and offset must be represented as rational values, not floating point values.
scale:
numerator: 1
denominator: 100
This avoids cross-language differences when generating code.
10.2 Transform Pipeline
The value transform pipeline is:
- Read raw register or bit data.
- Decode using storage type, width, sign, byte order, and word order.
- Apply scale.
- Apply offset.
- Apply clamp, if defined.
- Apply enum mapping, if defined.
- Return logical value.
Formula:
value = raw * scale + offset
10.3 Units
Units should use stable textual identifiers.
Recommended convention:
- Use UCUM-compatible units where possible.
- Allow practical electrical unit strings such as
W,kW,Wh,kWh,V,A,Hz,%,degC. - Linter should warn on unknown unit strings when a known unit exists.
10.4 Dynamic (Register-Referenced) Scaling (v0.4)
The static rational scale of §10.1 cannot express scaling whose factor is read
from another register at run time. This is mandatory for SunSpec, since every
SunSpec measurement has a companion scale-factor (SF) register and the
real value is raw · 10^SF. Several non-SunSpec meters do the same with a
decimal-point register or a CT/VT transformer-factor register (Eaton IQ,
Janitza short-format, Carrier).
v0.4 lets a transform reference another point for its scale:
# SunSpec-style: value = raw * 10^(value of the referenced SF point)
transform:
scale_ref:
point_id: ac_power_sf # the SF register, read at the same time
mode: POW10 # value = raw * 10^ref
# Generic register-referenced multiplier: value = raw * (value of ref) / div
transform:
scale_ref:
point_id: ct_ratio
mode: MULTIPLY
denominator: 1
Rules:
- Exactly one of
scale(static rational, §10.1) orscale_refmay be set. mode: POW10⇒value = raw · 10^ref(the SunSpec convention;refis a signed integer, oftenint16).mode: MULTIPLY⇒value = raw · ref / denominator.- The referenced point must resolve within the same device profile and is
read in the same transaction window; the linter errors if it does not
resolve (
MDE407, reserved). offset(§10.1) still applies after the dynamic scale.
This is the single most important v0.4 addition: it unblocks essentially every SunSpec device (Fronius, SolarEdge, SMA, Kostal, Solplanet, and parts of Sungrow).
10.5 Value, Scale, or Unit Selected by Another Register (v0.4)
Some devices change the meaning, scale decade, or unit of a register based on
the value of another register: an energy "format" register selecting
units/kilo/mega, a tariff selector gating which accumulator a register
represents, or an OBIS-coded quantity selector. A point may declare a
selector_ref describing this indirection:
selector_ref:
point_id: energy_format # value selects the interpretation below
cases:
0: { scale: { numerator: 1, denominator: 1 } } # units
1: { scale: { numerator: 1000, denominator: 1 } } # kilo
2: { scale: { numerator: 1000000, denominator: 1 } }# mega
The codec evaluates the referenced point, then applies the matching case. Lower frequency than §10.4 but required for a handful of meters.
11. Access Modes and Write Semantics
11.1 Access Modes
Supported access modes:
READ_ONLYWRITE_ONLYREAD_WRITECOMMAND
11.2 Command Points
Command points represent writes that trigger actions.
Examples:
- Reset energy counter.
- Clear alarm.
- Start charging.
- Stop inverter.
- Reboot device.
Command point fields may include:
write_behaviorconfirmation_pointallowed_valuesrequires_enable_point
11.3 Write Behaviors
Supported write behaviors:
DIRECTMOMENTARYLATCHMASKEDCOMMAND_TRIGGER
11.4 Write Constraints
A writable point may define:
- Minimum value
- Maximum value
- Step
- Allowed values
- Required enable condition
- Cooldown time
- Safety note
11.5 Distinct Write Encoding (v0.4)
A few devices encode a value differently on write than on read. For example,
Carlo Gavazzi accepts a command with a "magic" high byte that does not appear in
the read-back. A point may define a write_encoding block that overrides the
storage/transform used for writes:
access: READ_WRITE
storage_type: U16
transform: { scale: { numerator: 1, denominator: 10 } } # read path
write_encoding:
storage_type: U16
transform: { scale: { numerator: 1, denominator: 1 } } # write path
prefix_high_byte: 0x80 # device "magic"
When write_encoding is absent, the read encoding is used for writes
(the default and the common case).
11.6 Packed Command Arguments (v0.4)
Some command registers pack several operands into one written word (a sub-command id plus an argument, or HIWORD/LOWWORD operands). These are described as a write-side register-field struct (§13.1) on the command point, so each argument has its own bit range, type, and allowed values.
12. Enums
Enums are independent reusable type definitions.
An enum is not defined inside a register or point. A point references an enum type.
Example:
enums:
- type_id: operating_state
name: Operating State
values:
- value: 0
name: off
- value: 1
name: standby
- value: 2
name: running
- value: 3
name: fault
Point reference:
point_id: operating_state
storage_type: U16
value_type:
enum_ref:
type_id: operating_state
Validation rules:
- Enum names must be unique within an enum.
- Enum numeric values must be unique unless aliases are explicitly allowed.
- Referenced enum types must exist after import resolution.
- Linter must report unused enum definitions as optional warnings.
13. Bit Fields
A point may represent:
- A single bit.
- A range of bits.
- A named set of bit fields inside a register.
Example:
point_id: alarm_flags
name: Alarm Flags
mapping:
space: HOLDING_REGISTER
offset: 20
length_words: 1
bit_fields:
- field_id: over_voltage
bit_offset: 0
bit_length: 1
- field_id: under_voltage
bit_offset: 1
bit_length: 1
Multi-bit fields may reference an enum.
13.1 Register Field Extraction / Sub-fields (v0.4)
A bare bit_field (above) extracts a bit range as a boolean or small enum.
Real maps frequently pack several independent, fully-typed sub-values into
one register or register pair: HIWORD/LOWWORD halves, a byte-tuple firmware
version (major.minor.revision), a packed IPv4 address, or a packed-BCD blob
holding several sub-values (Eastron demand interval / scroll time / backlight).
v0.4 generalizes bit_fields so each sub-field carries its own type, scale,
and unit, i.e. a struct laid over a register window:
point_id: firmware_version
mapping: { space: HOLDING_REGISTER, offset: 8, length_words: 2 }
fields:
- field_id: major
bit_offset: 24
bit_length: 8
value_type: { primitive: UINT32 }
- field_id: minor
bit_offset: 16
bit_length: 8
value_type: { primitive: UINT32 }
- field_id: revision
bit_offset: 0
bit_length: 16
value_type: { primitive: UINT32 }
Each field may carry its own value_type, transform (including scale),
unit, enum_ref, and storage_type (e.g. BCD for a packed-BCD sub-value).
bit_offset is counted across the whole mapped window (LSB = 0). The legacy
bit_fields form remains valid and is the boolean/enum special case.
13.2 Flag Sets (FLAGS) (v0.4)
Status/alarm registers often expose many independent boolean flags that may be
true simultaneously (a bitmask), not a single enumerated state. Modeling each
as a separate bit_field is verbose; the FLAGS value type names them as a
set:
point_id: alarm_flags
storage_type: U32
value_type:
flags:
bits:
0: over_voltage
1: under_voltage
2: over_temperature
7: door_open
A decoded FLAGS value is the set of names whose bits are set. Unlisted bits
are preserved as raw for round-tripping.
14. Composed Values
Some devices represent a numeric value as multiple separate registers.
Example:
- Mantissa register.
- Exponent register.
This can represent scientific notation:
value = mantissa * base^exponent
Example:
point_id: active_power
storage_type: COMPOSED
composed:
kind: MANTISSA_EXPONENT
base: 10
mantissa:
space: HOLDING_REGISTER
offset: 100
storage_type: S16
exponent:
space: HOLDING_REGISTER
offset: 101
storage_type: S16
Validation rules:
- Both mantissa and exponent mappings must be valid.
- Base must be non-zero.
- Generated clients must expose the computed value, not the raw pair, unless raw access is requested.
14.1 Repeating Blocks and Arrays (v0.4)
Many devices repeat a group of registers a fixed or runtime-determined number
of times with a constant stride: multi-string PV inputs, per-phase groups,
per-cell/per-module battery data, and SunSpec repeating blocks. v0.4 adds
stride_words and an optional count_ref to the array/struct mapping:
point_id: cell_voltages
value_type:
array:
element_type: { primitive: DECIMAL }
# length: 16 # fixed count, OR:
count_ref: { point_id: cell_count } # count read from a register
mapping:
space: INPUT_REGISTER
offset: 200
stride_words: 1 # each element starts 1 word after the previous
element_transform:
scale: { numerator: 1, denominator: 1000 }
For arrays of structs, element_type references a struct whose fields use
window-relative offsets (§13.1) and stride_words is the size of one element.
count_ref resolves a register holding the live element count; when absent,
length is fixed.
15. Strings and Raw Bytes
String fields must define:
- Encoding
- Register length
- Padding behavior
- Termination behavior
Example:
storage_type: STRING_ASCII
length_words: 8
string_encoding:
charset: ASCII
padding: NULL
termination: NULL_TERMINATED
Recommended padding modes:
NULLSPACENONE
Recommended termination modes:
FIXED_LENGTHNULL_TERMINATED
16. Device Profiles
A device profile describes one device model, family, or reusable abstract device.
Fields:
device_idvendormodelfamilydescriptionfirmware_versionssupported_transportsdefault_unit_idblocksvariants
Example:
device_id: example-inverter
vendor: Example Corp
model: INV-5000
supported_transports:
- MODBUS_RTU
- MODBUS_TCP
17. Register Blocks
Register blocks group related points.
A block may correspond to:
- A contiguous Modbus address range.
- A functional group.
- A standard model block, such as a SunSpec model.
- A vendor-specific extension.
Fields:
block_idnamespacestart_offsetlength_wordspoints
Validation rules:
- Points in a block should fall within the block range.
- Overlaps are errors unless explicitly allowed.
- Blocks may overlap only if declared as overlays.
17.1 Conditional Point Presence (v0.4)
A point may exist only in some firmware versions, options, or device
compositions (an optional meter, a battery module, a model present only when a
feature register is set). Rather than forcing a separate variant for every
combination, a point or block may declare an available_if condition:
- point_id: battery_soc
available_if:
point_id: battery_present # gating register/point
equals: 1
mapping: { space: HOLDING_REGISTER, offset: 520, length_words: 1 }
storage_type: U16
value_type: { primitive: DECIMAL }
A reader skips an unavailable point instead of reading garbage. available_if
complements §7.3 discovery (which handles whole models appearing/moving) and
§18 variants (which handle named SKUs).
18. Variants
Variants represent firmware, hardware, regional, or option-specific differences.
Example use cases:
- Same model with different firmware.
- Optional energy meter module.
- Single phase and three phase versions.
- Solar inverter with optional battery module.
A variant may:
- Add points.
- Remove points.
- Override points.
- Override metadata.
Example:
variants:
- variant_id: firmware-2
name: Firmware 2.x
base_device_id: example-meter
removals:
- deprecated_alarm_register
additions:
- point_id: state_of_charge
...
19. Imports and Packages
Imports allow reuse of external definitions.
19.1 Import URI Types
Supported import URI styles:
./relative-file.moddef.yaml
../common/enums.moddef.yaml
moddef:stdlib:core:1.0.0
moddef:stdlib:measurands:1.0.0
moddef:stdlib:sunspec:1.0.0
19.2 Import Aliases
An import may define an alias.
imports:
- uri: moddef:stdlib:measurands:1.0.0
alias: meas
Qualified references may use the alias:
measurand_id: meas:active_power
19.3 Conflict Rules
- Local definitions override nothing implicitly.
- Conflicting imported identifiers are errors unless namespaced by alias.
- Re-exporting imports must be explicit.
19.4 Package Manifest
A ModDef package should include:
package_id: moddef:stdlib:measurands
version: 1.0.0
files:
- measurands.moddef.yaml
dependencies: []
checksum_algorithm: sha256
20. Standard Libraries
Recommended official packages:
20.1 Core Library
moddef:stdlib:core:<version>
Contains:
- Common primitive enums.
- Common status enums.
- Standard write behavior definitions.
- Common transform helpers.
20.2 Measurands Library
moddef:stdlib:measurands:<version>
Contains:
- Core ModDef measurand definitions.
- Electrical quantity taxonomy.
- Phase and reference definitions.
- Default unit compatibility rules.
20.3 SunSpec Library
moddef:stdlib:sunspec:<version>
Contains:
- SunSpec model mappings represented in ModDef.
- Versioned packages matching source model revisions where possible.
- Measurand annotations for common SunSpec fields.
20.4 Vendor Libraries
Vendors may publish packages such as:
vendor:acme:meters:1.2.0
vendor:acme:inverters:2026.04
21. Core Measurands System
The Core Measurands System defines semantic identifiers that can be referenced by points.
The goal is to allow generated clients to expose high-level APIs independent of vendor register addresses.
Example:
device.get_active_power(direction="import")
device.get_voltage(phase="L1-N")
device.get_frequency()
If a device does not support a measurand, the client must return or
raise a well-defined MeasurandNotSupported error.
22. Measurand Model
A measurand is composed of:
- Base quantity
- Optional direction
- Optional phase/reference
- Optional aggregation
- Optional location
- Optional context
- Optional accumulation kind
- Canonical unit
- Expected value type
22.1 Base Quantity
Examples:
active_powerreactive_powerapparent_powerenergy_activeenergy_reactiveenergy_apparentvoltagecurrentfrequencypower_factortemperaturestate_of_chargestate_of_healthdevice_statusalarm_status
22.2 Direction
Direction is used for quantities where flow direction matters.
Recommended values:
NONEIMPORTEXPORTNETCHARGEDISCHARGEFORWARDREVERSE
Examples:
base_quantity: active_power
direction: IMPORT
base_quantity: energy_active
direction: EXPORT
accumulation: REGISTER
22.3 Phase and Reference
Phase and reference are modeled using a phase reference enum inspired by OCPP phase values.
Do not encode every phase combination as a separate measurand name.
Instead, define the base measurand and qualify it with phase_ref.
Recommended PhaseRef values:
Value Meaning
NONE Not phase-specific
L1 Phase L1
L2 Phase L2
L3 Phase L3
N Neutral
L1_N L1 measured against neutral
L2_N L2 measured against neutral
L3_N L3 measured against neutral
L1_L2 Line-to-line measurement from L1 to L2
L2_L3 Line-to-line measurement from L2 to L3
L3_L1 Line-to-line measurement from L3 to L1
Textual wire values may be rendered in OCPP-compatible form:
ModDef enum Text value
L1_N L1-N
L2_N L2-N
L3_N L3-N
L1_L2 L1-L2
L2_L3 L2-L3
L3_L1 L3-L1
Examples:
Voltage between L1 and neutral:
measurand:
base_quantity: voltage
phase_ref: L1_N
Voltage between L1 and L2:
measurand:
base_quantity: voltage
phase_ref: L1_L2
Current on L1:
measurand:
base_quantity: current
direction: IMPORT
phase_ref: L1
Total active power import:
measurand:
base_quantity: active_power
direction: IMPORT
aggregation: TOTAL
22.4 Aggregation
Aggregation describes whether a measurement is per phase, total, average, minimum, maximum, or otherwise aggregated.
Recommended values:
NONETOTALAVERAGEMINIMUMMAXIMUMINSTANTANEOUS
Examples:
base_quantity: active_power
direction: IMPORT
aggregation: TOTAL
base_quantity: voltage
phase_ref: L1_N
aggregation: INSTANTANEOUS
22.5 Location
Location describes where the measurement is taken.
Recommended values:
UNSPECIFIEDGRIDLOADINLETOUTLETBATTERYPVINVERTEREVCABLEBODYINTERNALEXTERNAL
This is useful for devices with multiple measurement locations.
Examples:
base_quantity: temperature
location: INTERNAL
base_quantity: energy_active
direction: IMPORT
location: GRID
accumulation: REGISTER
22.6 Accumulation
Accumulation describes whether a value is instantaneous, a cumulative register, or an interval value.
Recommended values:
INSTANTANEOUSREGISTERINTERVALLIFETIMESESSIONDAILYMONTHLY
Example:
base_quantity: energy_active
direction: IMPORT
accumulation: REGISTER
22.7 Context
Context describes why or when a value is sampled.
Recommended values:
UNSPECIFIEDSAMPLE_PERIODICSAMPLE_CLOCKTRANSACTION_BEGINTRANSACTION_ENDINTERRUPTION_BEGININTERRUPTION_ENDTRIGGEREDMANUAL
Context is optional and is primarily useful for telemetry streams, not static register maps.
23. Core Measurand Catalog
The core catalog should be extensible. It should not try to include every possible vendor-specific concept in v1.0.
23.1 Electrical Power
Base quantity Direction Default unit Notes
active_power import, export, W Instantaneous
net real power
reactive_power import, export, var Instantaneous
net reactive power
apparent_power none, import, VA Instantaneous
export apparent power
power_factor none 1 Ratio,
dimensionless
Phase-qualified examples:
active_power, directionIMPORT, phase_refL1active_power, directionIMPORT, phase_refL2active_power, directionIMPORT, phase_refL3active_power, directionIMPORT, aggregationTOTAL
23.2 Electrical Energy
Base quantity Direction Accumulation Default unit
energy_active import, export, net register Wh
energy_reactive import, export, net register varh
energy_apparent import, export register VAh
OCPP-style names can be mapped as aliases:
OCPP-style name ModDef semantic tuple
Energy.Active.Import.Register base energy_active, direction
IMPORT, accumulation REGISTER
Energy.Active.Export.Register base energy_active, direction
EXPORT, accumulation REGISTER
Energy.Reactive.Import.Register base energy_reactive, direction
IMPORT, accumulation REGISTER
Energy.Reactive.Export.Register base energy_reactive, direction
EXPORT, accumulation REGISTER
23.3 Voltage
Base quantity Phase reference Default unit
voltage L1_N V
voltage L2_N V
voltage L3_N V
voltage L1_L2 V
voltage L2_L3 V
voltage L3_L1 V
voltage none V
Important rule:
- Phase-to-neutral and line-to-line voltages are different semantic measurements.
- A point with
phase_ref: L1should not be treated as equivalent tophase_ref: L1_Nunless the device profile explicitly declares that equivalence.
23.4 Current
Base quantity Direction Phase reference Default unit
current import L1 A
current import L2 A
current import L3 A
current export L1 A
current export L2 A
current export L3 A
current none N A
23.5 Frequency
Base quantity Default unit
frequency Hz
Frequency is normally not phase-specific.
23.6 Battery and Storage
Base quantity Default unit Notes
state_of_charge % Battery charge level
state_of_health % Battery health
battery_voltage V Battery terminal voltage
battery_current A Battery current
battery_power W Battery power
charge_limit W or A Unit must disambiguate
discharge_limit W or A Unit must disambiguate
23.7 Solar and Inverter
Base quantity Default unit
pv_voltage V
pv_current A
pv_power W
dc_bus_voltage V
inverter_temperature degC
inverter_status enum
23.8 Thermal
Base quantity Location Default unit
temperature internal degC
temperature external degC
temperature battery degC
temperature inverter degC
23.9 Status and Alarms
Base quantity Default type
device_status enum
alarm_status enum or bit field
fault_code enum or integer
warning_code enum or integer
communication_status enum
24. Measurand Aliases
ModDef should support aliases to map external naming systems to the ModDef semantic tuple model.
Example:
measurand_aliases:
- alias: Energy.Active.Import.Register
source: OCPP_1_6
maps_to:
base_quantity: energy_active
direction: IMPORT
accumulation: REGISTER
Example:
measurand_aliases:
- alias: Voltage.L1-N
source: OCPP_1_6
maps_to:
base_quantity: voltage
phase_ref: L1_N
This allows ModDef to be compatible with OCPP terminology without making OCPP the core namespace.
25. Measurand Validation
Linter and compiler validation rules:
- A point may have zero or one measurand annotation.
- A device profile must not expose duplicate semantic measurands unless explicitly allowed.
- If duplicate measurands are allowed, they must differ by location, phase reference, context, or role.
- The point unit must be compatible with the measurand canonical unit.
- The point value type must be compatible with the measurand expected type.
- Phase references must be valid for the base quantity.
- Line-to-line voltage values must use
L1_L2,L2_L3, orL3_L1. - Line-to-neutral voltage values must use
L1_N,L2_N, orL3_N. - Current and per-phase power normally use
L1,L2, orL3, notL1_N. - Frequency should not be phase-specific unless explicitly allowed by a profile extension.
Example linter messages:
MDE101: Unknown measurand base_quantity 'active_powr'.
MDE102: Point 'voltage_l1' uses unit 'A', expected unit compatible with 'V'.
MDE103: Duplicate measurand voltage/L1_N in device profile 'meter-a'.
MDW104: Point 'frequency_l1' uses a phase reference for frequency.
26. Generated Measurand APIs
Generated client libraries should expose both generic and convenience accessors.
26.1 Generic API
Python example:
device.get_measurand(
base_quantity="voltage",
phase_ref="L1-N"
)
Go example:
device.GetMeasurand(ctx, moddef.MeasurandQuery{
BaseQuantity: moddef.Voltage,
PhaseRef: moddef.PhaseL1N,
})
26.2 Convenience API
Python:
device.get_frequency()
device.get_voltage(phase="L1-N")
device.get_active_power(direction="import", aggregation="total")
device.get_energy_active(direction="import", accumulation="register")
Go:
device.Frequency(ctx)
device.Voltage(ctx, moddef.PhaseL1N)
device.ActivePower(ctx, moddef.Import, moddef.Total)
device.EnergyActive(ctx, moddef.Import, moddef.Register)
26.3 Unsupported Measurands
If a measurand is not supported, clients must raise or return a standard error.
Python:
raise MeasurandNotSupported("voltage", phase_ref="L1-N")
Go:
return 0, moddef.ErrMeasurandNotSupported
26.4 Ambiguous Measurands
If multiple points match a query, the client must not choose silently.
It must either:
- Return an ambiguity error.
- Require additional query qualifiers.
- Use a profile-defined default if explicitly declared.
27. Protobuf Schema Draft
This schema is a starting point for development. Field numbers are draft
and may change before v1.0. It reflects v0.4 (it now also includes the §11
write semantics, §15 string encoding, §14 composed kind, §7.2 function-code
constraints, §16 firmware_versions, and §17 overlay flag that the v0.3 draft
described in prose but omitted from the schema, plus all v0.4 additions).
The canonical schema is the Protobuf definition in
moddef/proto/moddef/v1:
types.proto: primitive and value types, enums, transportsmapping.proto: physical mapping, transforms, fields, strings, write semanticsdevice.proto: device profiles, register blocks, points, variantsmeasurand.proto: the measurand model and aliasesdocument.proto: the top-level document and imports
The full schema is omitted here to keep the rendered spec readable; the
.proto files above are the source of truth.
28. Validation Rules
A compliant ModDef document must satisfy the following.
28.1 Structural Validation
- Document id must be present.
- Document version must be present.
- Device profile ids must be unique.
- Point ids must be unique within a device profile.
- Enum ids must be unique after imports are resolved.
- Struct ids must be unique after imports are resolved.
- Measurand ids must be unique after imports are resolved.
28.2 Import Validation
- All imports must resolve.
- Import checksums must match if specified.
- Import cycles must be detected.
- Name collisions must be rejected unless aliased.
28.3 Mapping Validation
- Register ranges must not overlap unless explicitly declared as overlays.
- Points inside a register block must fall within the block range.
- Multi-word values must define byte order and word order, or emit a warning.
- Bit index must be valid for the mapped width.
- Coil and discrete input mappings must not define
length_words.
28.4 Type Validation
- Storage type and value type must be compatible.
- Enum references must resolve.
- Struct references must resolve.
- String storage must define length and encoding.
- Scale denominator must not be zero.
28.5 Access Validation
- Read-only points must not be generated with write methods.
- Write-only points must not be generated with read methods unless explicitly allowed as unsafe raw access.
- Command points must define write semantics.
28.6 Measurand Validation
- Measurand references must resolve if using
measurand_id. - Inline semantic tuples must use known base quantities unless vendor extension mode is enabled.
- Phase references must be compatible with base quantity.
- Units must be compatible with the canonical unit.
- Duplicate semantic measurands must be rejected unless qualified or explicitly allowed.
28.7 v0.4 Construct Validation
transformmust set at most one ofscaleandscale_ref.- A
scale_ref/selector_ref/available_if/count_reftarget must resolve to a point in the same device profile (MDE407, reserved). - A point whose
value_type.primitiveisDATETIMEmust definedatetime. - A point whose
value_type.kindisflagsmust define at least one bit. na_valuesraw patterns must fit the decoded storage width.- A
RegisterField(fields) range must lie within the mapped window and fields must not overlap unless explicitly allowed. - A block with
discovery.kind = SUNSPECmust usemodel_relative_offseton its points (not absoluteoffset). stride_wordsmust be ≥ the element width for repeating arrays.
29. Tooling Specification
29.1 CLI Tool
Command:
moddef
Required commands:
moddef build
moddef lint
moddef fmt
moddef convert
moddef pack
moddef gen
moddef inspect
29.1.1 build
Builds .moddef.yaml or .moddef.json into .moddef.
moddef build device.moddef.yaml
29.1.2 lint
Validates the document and reports errors and warnings.
moddef lint device.moddef.yaml
29.1.3 fmt
Rewrites YAML or JSON into canonical formatting.
moddef fmt device.moddef.yaml
29.1.4 convert
Converts between supported encodings.
moddef convert device.moddef.yaml device.moddef.json
moddef convert device.moddef.json device.moddef
29.1.5 pack
Builds a reusable package.
moddef pack ./stdlib/measurands
29.1.6 gen
Generates client code.
moddef gen --lang python --input device.moddef.yaml --out ./generated
29.1.7 inspect
Prints a human-readable summary.
moddef inspect device.moddef
29.2 Exit Codes
Code Meaning
0 success 1 validation error 2 parse error 3 import resolution error 4 generation error 5 internal error
30. Linter Rules
Recommended linter rule categories:
30.1 Errors
- Overlapping mappings.
- Unknown enum reference.
- Unknown measurand reference.
- Invalid scale denominator.
- Invalid phase reference.
- Unit incompatible with measurand.
- Unsupported storage and logical type combination.
30.2 Warnings
- Missing description.
- Missing endianness for multi-word point.
- Unknown unit string.
- Unused enum.
- Unused import.
- Duplicate human-readable names.
- Suspicious 1-based address converted to 0-based.
30.3 Linter Configuration
File:
.moddeflintrc.yaml
Example:
rules:
MDW001:
severity: error
MDW_DESCRIPTION_MISSING:
severity: warning
31. Code Generation
Generated code should provide:
- Typed point access.
- Generic point access by id.
- Generic measurand access.
- Convenience measurand methods.
- Enum types.
- Decode and encode functions.
- Optional Modbus transport wrappers.
Supported target languages:
- Python
- Go
- TypeScript
- C
- C++
- Rust
32. Client Library Architecture
Client libraries should be layered.
32.1 Core Model
- Loads ModDef documents.
- Resolves imports.
- Provides metadata access.
32.2 Codec Layer
- Decodes Modbus register responses into typed values.
- Encodes write values into Modbus payloads.
- Handles endianness, scaling, enum mapping, strings, bit fields, and composed values.
32.3 Transport Adapter
Defines an interface for Modbus operations:
read_coilsread_discrete_inputsread_input_registersread_holding_registerswrite_single_coilwrite_multiple_coilswrite_single_registerwrite_multiple_registers
32.4 Device Facade
Generated high-level API for a device profile.
Example:
meter = ExampleMeter(client)
meter.get_frequency()
meter.get_voltage(phase="L1-N")
meter.get_energy_active(direction="import", accumulation="register")
33. Testing and Compliance
A compliance suite should include:
- Golden files.
- YAML to JSON equivalence tests.
- JSON to binary equivalence tests.
- Binary decoding tests.
- Invalid document fixtures.
- Linter fixture tests.
- Code generation tests.
- Cross-language decode/encode tests.
- Optional hardware-in-loop tests.
34. Recommended Implementation Roadmap
- Define Protobuf schema.
- Implement parser and canonical model.
- Implement YAML and JSON conversion.
- Implement binary
.moddefwriter and reader. - Implement import resolution.
- Implement linter.
- Implement codec in one reference language.
- Implement core measurand library.
- Implement generated clients for one language.
- Add SunSpec package mapping.
- Add additional language generators.
- Publish compliance suite.
35. External References
This section is informational.
- Modbus Application Protocol Specification, for protocol-level address spaces and function codes.
- SunSpec Modbus models, for solar, inverter, and storage examples.
- OCPP measurands and phase values, for semantic measurement naming
inspiration, especially phase references such as
L1,L2,L3,N,L1-N,L2-N,L3-N,L1-L2,L2-L3, andL3-L1.
36. Design Notes
36.1 Why Not Use OCPP Measurands Directly?
OCPP measurands are useful and well known in EV charging, but they are domain-specific.
ModDef should not make EV charging terminology the universal core model. Instead, ModDef uses a semantic tuple model and provides OCPP-compatible aliases.
36.2 Why Not Encode voltage_l1_n as a Separate Core Measurand?
A flat list becomes hard to scale.
Instead of defining every combination as a separate name:
voltage_l1_n
voltage_l2_n
voltage_l3_n
voltage_l1_l2
ModDef defines:
base_quantity = voltage
phase_ref = L1_N
This is easier to validate, query, and generate.
36.3 Why Keep Protobuf Internal?
Protobuf provides a stable schema and compact binary encoding.
End users can still work with YAML and JSON, while tool authors can generate code and binary files from a canonical schema.
37. Revision History
v0.4 (2026-06-04)
Driven by validating v0.3 against 136 real device Modbus specifications across
energy meters, solar inverters, battery storage, EV chargers, and HVAC heat
pumps. The full analysis (coverage, per-device verdicts, and the ranked gap
list) is in research/MODDEF-GAP-ANALYSIS.md; per-file reports are in
research/_gap_reports/. Of 90 real Modbus register specs, 16 were fully
expressible in v0.3 and 91 were partial, collapsing to the ~10 recurring
constructs added here.
Added (blockers, without which common devices decode incorrectly):
- Dynamic (register-referenced) scaling (§10.4,
ScaleRef): value =raw · 10^SFwhereSFis another register. Mandatory for SunSpec; also used by Eaton IQ, Janitza, Carrier. Highest-impact change; unblocks essentially every SunSpec device. - Unavailable / sentinel values (§8.4,
na_values): declare raw patterns such as0x8000/0xFFFF/NaN that mean "no data" rather than a reading. - Dynamic / discovered addressing (§7.3,
Discovery): SunSpecSunSmodel-chain walk and model-relative offsets. - Conditional point presence (§17.1,
available_if).
Added (important, broad and cross-domain):
- Register-field structs (§13.1,
RegisterField): typed sub-fields with their own type/scale/unit (HIWORD/LOWWORD, packed BCD, version tuples, IPv4). - Flag sets (§13.2,
FLAGS): multi-flag status registers. - Date/time values (§8.5,
DATETIME+DateTimeSpec): epoch s/ms, packed BCD, split fields. - Repeating arrays with stride / count reference (§14.1,
stride_words+count_ref): multi-string PV, per-cell battery. - Value/scale/unit selected by another register (§10.5,
SelectorRef).
Added (moderate / localized):
- Extended storage widths
U24,U48,S48and asign_magnitudeflag (§8.2). - Distinct write encoding (§11.5) and packed command arguments (§11.6).
Schema consistency fixes (prose features the v0.3 §27 draft had omitted):
write semantics (WriteSemantics/WriteConstraints), string encoding
(StringEncoding), composed kind, per-point function-code constraints,
DeviceProfile.firmware_versions, and RegisterBlock.overlay are now present
in the §27 schema.
Explicitly out of scope (documented in §3, not added): non-Modbus transports: Tesla (DNP3/REST), sonnen (REST), Pylontech US (proprietary ASCII), Vaillant/Bosch (eBUS/EMS), Easee/Zaptec/EVBox/ChargePoint (OCPP/REST), and BACnet-only devices. ModDef remains a Modbus description format.
v0.3
Initial public draft: document model, four address spaces, logical/storage type split, endianness, rational scaling, access modes, enums, bit fields, composed values, strings, device profiles, register blocks, variants, imports and packages, the core measurand tuple system and catalog, the §27 Protobuf schema draft, validation/linter rules, tooling, and compliance testing.