33. C37.118 synchrophasor ingest¶
This chapter specifies how GridSim reads IEEE C37.118.2 synchrophasor streams: the frame decoder, the CONFIG-1/2
parser, the phasor data concentrator (PDC), the sample-quality rule, the PDC-to-MeasurementSet mapper and its
channel-map file, the live TCP / UDP receiver and its quarantine, the linear PMU state estimator and its adapter, and
the two verbs, pmu-ingest and pmu-estimate. The code lives in src/GridSim.Core/Synchrophasor/,
src/GridSim.Core/Estimation/LinearPmuStateEstimator.cs and PmuMeasurementSetAdapter.cs; the internal
HybridMultiRateScheduler is in src/GridSim.Core/Simulation/.
The whole subject is experimental. The path that runs end to end is: gridsim pmu-ingest <capture.bin> (or
--live from a device) frames the stream, checks every CRC, parses each CONFIG frame, decodes every multi-PMU DATA
frame against the configuration in force for it, refuses the run if any frame is newer than the horizon, drops every
sample the quality rule rejects, aligns the rest on their reporting instants with the PDC and prints a summary; with a
channel map it writes one gridsim-measurements/1 set per aligned instant through the mapper (and so through the
horizon guard again). gridsim pmu-estimate <case> --measurements <set.json> reads such a set and runs the linear PMU
estimate. The design record, which also describes parts that are not built, is
IEEE C37.118.2 Synchrophasor Ingest + Phasor Data Concentrator; this chapter
specifies only what the code does.
Frames are decoded in engineering units (volts, amperes, hertz, hertz per second). A PMU angle is the PMU's own angle,
referenced to a UTC-synchronised nominal-frequency cosine, not to any bus of a case; the mapper is where angles are
re-referenced to the estimator's slack frame. Chapter 6 (State estimation)
specifies the WLS estimator that consumes MeasurementSet rows, and chapter 18
(Real-time distribution and dynamic estimation)
places this subsystem among the resident estimators.
Model¶
What is wired¶
| Stage | Type | Called by |
|---|---|---|
| Frame header and CRC | C37118CommonHeader, C37118Crc, C37118Decoder.VerifyCrc |
pmu-ingest, the decoder, the config parser |
| CONFIG-1/2 parse | C37118ConfigParser.Parse |
pmu-ingest |
| DATA decode, whole stream | C37118Decoder.DecodeData(frame, C37118ConfigFrame) |
pmu-ingest |
| DATA decode, single PMU | C37118Decoder.DecodeData(frame, timeBase, PmuConfig) and the float-only overload |
tests only |
| Sample quality | C37118SampleQuality.Of(timeQuality, stat) |
pmu-ingest |
| Alignment | PhasorDataConcentrator |
pmu-ingest (wait window zero, one FlushAll) |
| Channel map file | PmuChannelMapFile.Load, ValidateAgainst |
pmu-ingest --channels |
| Mapping to estimator rows | PmuMeasurementSetMapper.Map |
pmu-ingest --channels ... --measurements-dir |
| Live receive | C37118LiveReceiver.ReceiveTcp, ReceiveUdp |
pmu-ingest --live |
| Live quarantine | PmuLiveQuarantine |
pmu-ingest --live ... --live-quarantine |
| Polar set to rectangular phasors | PmuMeasurementSetAdapter.FromSet |
pmu-estimate |
| Linear estimate | LinearPmuStateEstimator.Estimate |
pmu-estimate |
| Update schedule | HybridMultiRateScheduler.Schedule (internal) |
tests only |
PhasorDataConcentrator, PmuMeasurementSetMapper and LinearPmuStateEstimator carry the marker
<b>Experimental</b> (R062, October 2026) in their type comments; HybridMultiRateScheduler is marked EXPERIMENTAL (R095).
R062EstimationWiringTests.Stale_docs_are_corrected_and_test_only_classes_are_marked_experimental pins the first
three markers by reading the source files.
Common header (14 bytes, big-endian)¶
C37118CommonHeader.Parse reads the start of every frame:
| Offset | Bytes | Field | Handling |
|---|---|---|---|
| 0 | 1 | SYNC byte 0 | Must be 0xAA; any other value throws ArgumentException. |
| 1 | 1 | SYNC byte 1 | Bits 6..4 are the frame type, bits 3..0 the version. Bit 7 is ignored; the version is read and not validated. |
| 2 | 2 | FRAMESIZE | Whole frame in bytes, header to CHK inclusive. |
| 4 | 2 | IDCODE | Stream, PDC or PMU id. |
| 6 | 4 | SOC | Seconds since 1970-01-01T00:00:00Z. |
| 10 | 4 | FRACSEC | Top byte: message time-quality flags. Low 24 bits: fraction-of-second count. |
A buffer shorter than 14 bytes throws ArgumentException. Frame types (C37118FrameType):
| Value | Type | Decoded |
|---|---|---|
| 0 | Data |
yes |
| 1 | Header |
no |
| 2 | Config1 |
yes, same layout as CONFIG-2 for the fields read |
| 3 | Config2 |
yes |
| 4 | Command |
no |
| 5 | Config3 |
no |
Values 6 and 7 are read as undefined enum values and treated as "other" by pmu-ingest. The time-quality byte is
stored in C37118CommonHeader.TimeQuality and read by nothing else.
Time¶
The reporting instant is computed by C37118Decoder for every overload with the same expression:
ticks = SOC * 10^7 + floor(FRACSEC * 10^7 / TIME_BASE) (integer 100 ns ticks, 64-bit)
TimeUtc = UnixEpoch + ticks
TIME_BASE comes from the CONFIG frame, masked to its low 24 bits (0x00FFFFFF). Both products are exact in a 64-bit
integer (SOC is below 2^32 and FRACSEC below 2^24), so the only rounding is the one floor, which acts only when
TIME_BASE does not divide FRACSEC * 10^7 (a time finer than one tick); the instant is then the last whole tick at or
before the true time. At TIME_BASE = 1,000,000 every FRACSEC count lands on its exact tick (820,000 is .82 s). A
TIME_BASE of 0, or one wider than 24 bits, is refused by every decode overload (ArgumentOutOfRangeException naming
TIME_BASE), and a DATA frame whose FRACSEC is not below TIME_BASE is Malformed.
CRC-CCITT¶
C37118Crc.Compute is CRC-16/CCITT-FALSE: width 16, polynomial 0x1021, initial value 0xFFFF, no reflection, no
final XOR, bit-serial:
crc = 0xFFFF
for each byte b: crc = crc XOR (b << 8)
repeat 8 times: crc = (crc & 0x8000) ? ((crc << 1) XOR 0x1021) : (crc << 1) (16-bit)
The check vector "123456789" gives 0x29B1. C37118Decoder.VerifyCrc(frame) reads FRAMESIZE, returns false when the
buffer is shorter than 4 bytes, when FRAMESIZE is below 4 or when FRAMESIZE exceeds the buffer, and otherwise compares
the last two bytes of the declared frame (CHK) with the CRC of every byte before them. Bytes after the declared frame
are ignored.
CONFIG-1/2 frame¶
C37118ConfigParser.Parse reads, after the common header:
| Field | Bytes | Kept as |
|---|---|---|
| TIME_BASE | 4 | C37118ConfigFrame.TimeBase (low 24 bits) |
| NUM_PMU | 2 | number of PMU blocks that follow |
| per PMU: STN | 16 | PmuConfig.StationName (ASCII, trailing spaces trimmed) |
| IDCODE | 2 | PmuConfig.IdCode |
| FORMAT | 2 | PmuConfig.Format (low 4 bits) |
| PHNMR, ANNMR, DGNMR | 2 each | PhasorCount, AnalogCount, DigitalCount |
| CHNAM | 16 * (PHNMR + ANNMR + 16 * DGNMR) |
skipped |
| PHUNIT | 4 per phasor | PmuConfig.PhasorUnits, verbatim |
| ANUNIT, DIGUNIT | 4 per analogue, 4 per digital word | skipped |
| FNOM | 2 | bit 0 set gives Is50Hz; NominalHz is 50 or 60 |
| CFGCNT | 2 | ConfigCount |
| DATA_RATE | 2, signed | C37118ConfigFrame.DataRate (frames per second when positive, seconds per frame when negative) |
A CONFIG frame sizes and scales every DATA frame that follows, so the parser checks it before trusting its fields.
A TIME_BASE of 0 is refused by name. The checks run in this order, and every failure after the header checks throws C37118FrameException, which
derives from ArgumentException and names what is wrong and where:
- The common header parses (else
ArgumentException), and the type isConfig1orConfig2(elseArgumentException: "not a CONFIG frame"). - FRAMESIZE is at least 24 bytes: header 14, TIME_BASE 4, NUM_PMU 2, DATA_RATE 2, CHK 2.
- FRAMESIZE does not exceed the bytes supplied (a truncated capture or a short read).
- The trailing CHK equals the CRC-CCITT of the frame before it. The message carries both check words.
- Every later read lies inside the declared frame with CHK excluded. The fixed part of each PMU block, then its
variable part (names, units, FNOM, CFGCNT), is size-checked whole before it is read, so a count that runs past the
frame never allocates a PHUNIT table. The message names the PMU block (
kofn) and the field. - DATA_RATE (2 bytes) fits between the last PMU block and CHK.
Bytes left between DATA_RATE and CHK are accepted: the CRC vouches for them as sent.
FORMAT word¶
The FORMAT value is cast straight into C37118DataFormat after masking with 0x0F. The flag values are the
IEEE C37.118.2-2011 bit positions:
| Bit | Flag | Value | Set means |
|---|---|---|---|
| 0 | PhasorPolar |
1 | phasors are magnitude and angle, else real and imaginary |
| 1 | PhasorFloat |
2 | phasors are 32-bit IEEE floats, else 16-bit integers |
| 2 | AnalogFloat |
4 | analogue values are 32-bit floats, else 16-bit integers |
| 3 | FreqFloat |
8 | FREQ and DFREQ are 32-bit floats, else 16-bit integers |
None (0) is fixed 16-bit rectangular phasors with integer frequency and analogues. A word such as 0x000E is
float rectangular phasors, float analogues and float FREQ/DFREQ.
DATA frame¶
A PMU block is, in order: STAT (2 bytes), the phasors, FREQ and DFREQ, the analogue values, the digital words. Its
size, which C37118Decoder uses both to check the frame length and to step to the next block, is:
block bytes = 2 + PHNMR * phasorSize + freqSize + ANNMR * analogSize + 2 * DGNMR
phasorSize = 8 if PhasorFloat else 4 freqSize = 8 if FreqFloat else 4 (FREQ and DFREQ together)
analogSize = 4 if AnalogFloat else 2
frame bytes = 14 + sum of block bytes over the PMUs, in CONFIG order + 2
Every value is decoded into engineering units as follows. scale_i = (PHUNIT_i & 0x00FFFFFF) / 100000.0
(PmuConfig.PhasorScale: one correctly rounded division; 100000 is exact in binary floating point), which is volts or
amperes per count.
float phasor, rectangular real = float32 at +0 imag = float32 at +4
float phasor, polar mag = float32 at +0 ang = float32 at +4 (radians)
integer phasor, rectangular real = int16 at +0 * scale_i imag = int16 at +2 * scale_i
integer phasor, polar mag = uint16 at +0 * scale_i ang = int16 at +2 * 1e-4 (radians)
polar to rectangular Phasor.FromPolar: real = mag * cos(ang) imag = mag * sin(ang)
float FREQ / DFREQ FreqHz = float32 RocofHzPerS = float32
integer FREQ / DFREQ FreqHz = NominalHz + int16 / 1000.0 RocofHzPerS = int16 / 100.0
An integer FREQ is a deviation from FNOM in millihertz and an integer DFREQ is ROCOF times 100, which is why an
integer frame cannot be decoded without the CONFIG frame. Analogue values and digital words are stepped over, not
decoded. Phasor.Magnitude is sqrt(Real^2 + Imag^2) and Phasor.AngleRad is atan2(Imag, Real).
The STAT word is returned verbatim in C37118DataFrame.Stat and C37118PmuBlock.Stat; the decoder does not interpret
its bits. C37118SampleQuality does, for pmu-ingest: STAT bits 15-14 (data error), 13 (PMU sync), 8-6 (time quality)
and 5-4 (unlocked time) decide whether a block is used, together with the frame's FRACSEC time quality (see
Sample quality). pmu-ingest also counts how many STAT words are non-zero.
Decode entry points¶
| Overload | Reads | Refuses |
|---|---|---|
DecodeData(frame, timeBase, phasorCount, format) |
one PMU block, float formats only, zero analogue and digital channels | an integer-phasor format (when phasorCount > 0) or an integer FREQ format: ArgumentException, because integer fields need FNOM and PHUNIT |
DecodeData(frame, timeBase, PmuConfig) |
the first PMU block, any format, with that PMU's analogue and digital sizes | an integer-phasor PmuConfig without a complete PHUNIT table: ArgumentException |
DecodeData(frame, C37118ConfigFrame) |
one block per PMU of the CONFIG frame, in its order | a CONFIG frame with no PMU, or any integer-phasor PMU without a complete PHUNIT table: ArgumentException, before a byte is read |
The two single-PMU overloads read only the first block of a multi-PMU frame; a stream with more than one PMU must use
the stream overload. Every overload returns a status instead of throwing for frame-level defects
(FrameDecodeStatus):
| Status | Meaning |
|---|---|
Ok |
CRC verified, frame is a DATA frame and long enough. |
CrcMismatch |
VerifyCrc failed. Nothing else in the frame is read. |
NotDataFrame |
The CRC passed and the type is not Data. |
Malformed |
The frame is too short for its configuration. The single-PMU overloads require Length >= need and FRAMESIZE >= need; the stream overload requires Length >= need and FRAMESIZE == need exactly, so a frame with the wrong number of blocks is refused whole, never half-read. |
The order of checks is CRC, then type, then length, so a corrupt frame is never classified by its (untrusted) header
fields. C37118StreamDataFrame.StreamIdCode is the frame header's IDCODE, 0 when the CRC failed. Each
C37118PmuBlock takes its IDCODE and station name from the CONFIG frame; a block carries no id of its own.
Phasor data concentrator¶
PhasorDataConcentrator(expectedPmuIds, waitWindow) aligns PMU samples (PmuSample: id, TimeUtc, phasors, frequency,
ROCOF) onto reporting instants. It is pure transport, deterministic and not synchronised for concurrent use.
- Ingest. The bucket key is
TimeUtc.Ticks. A later sample for the same PMU and instant replaces the earlier one. A sample whose key is at or before the high-water mark (the last emitted instant) is late: it is discarded, counted inLateSamplesandIngestreturnsfalse. Otherwise it returnstrue. - Flush.
Flush(watermark)computescutoff = watermark - waitWindowand emits, in ascending time order, every bucket whose key is at or beforecutoff. Each emittedPdcSuperframeholdsTimeUtc, the samples keyed by PMU id, andDropped: the expected ids with no sample at that instant, in ascending order. Emitted buckets are removed and the last emitted key becomes the high-water mark.FlushAll()isFlush(DateTime.MaxValue). - Expected ids. Duplicates in
expectedPmuIdscollapse. A sample from an id that is not expected is kept in the superframe and is never listed as dropped.
A superframe is emitted once. A straggler for an emitted instant cannot recreate its bucket, so no second superframe for the same instant, holding only the straggler, ever follows.
Mapping a superframe to estimator rows¶
PmuMeasurementSetMapper.Map(frame, maps, baseMva = 100.0, caseName = "pmu", utcNow = null, angleReference = null)
builds a MeasurementSet (src/GridSim.Core/Estimation/MeasurementSet.cs; the row type is in Measurement.cs). A PmuChannelMap lists, per PMU, voltage
channels (PmuVoltageChannel: phasor index, bus id, line-to-neutral BaseVolts, VmSigmaPu, VaSigmaDeg) and
current channels (PmuCurrentChannel: phasor index, from and to bus, optional circuit, BaseAmps, IMagSigmaPu,
IAngSigmaDeg). The row for each channel is:
voltage channel at bus b: VmPu = |V| / BaseVolts sigma = VmSigmaPu Bus = b
VaDeg = slackFrameDeg(V) sigma = VaSigmaDeg Bus = b
current channel on a->b: IMagPu = |I| / BaseAmps sigma = IMagSigmaPu From = a, To = b, Circuit
IAngDeg = slackFrameDeg(I) sigma = IAngSigmaDeg From = a, To = b, Circuit
slackFrameDeg(P) = wrap( angle(P) - angle(reference) ) * 180 / pi + AnchorDeg
wrap(d): d - 2*pi if d > pi; d + 2*pi if d <= -pi; else d (result in (-pi, pi])
Every row has Source = "pmu". The set carries Case = caseName, BaseMva, TakenUtc = frame.TimeUtc and the note
mapped from C37.118 PDC superframe. Rows are emitted in maps order, voltages before currents within a PMU, so the
output order does not depend on dictionary enumeration. A PMU in maps that is absent from frame.Samples (dropped
at this instant) contributes no rows.
Angle re-referencing. A C37.118 angle is absolute against a UTC-synchronised cosine at nominal frequency, so it
rotates with any frequency deviation; the estimator's VaDeg and IAngDeg rows are measured from the slack bus.
PmuAngleReference(PmuId, PhasorIndex, AnchorDeg = 0) names one voltage channel whose angle every other angle is
measured against. The common UTC-frame offset cancels in the difference; each angle is its own atan2, so the cancellation is not bit for bit (the test holds it to 9 decimal places for a 1.234 rad rotation). AnchorDeg is the slack-frame
angle of the bus the reference channel measures: 0 when the channel is at the slack bus. The mapper fails loud:
| Condition | Result |
|---|---|
| Frame time newer than the horizon | ForwardInferenceException, before anything else |
angleReference names a PMU or phasor that is not a mapped voltage channel |
ArgumentException, before any data is read |
An angle row is needed and no angleReference was given |
InvalidOperationException naming the UTC reference |
| The reference PMU did not report at this instant | InvalidDataException ("did not report"), no absolute angle is emitted |
| The reference PMU's sample has no such phasor | InvalidDataException |
Magnitude rows need no reference. A superframe that yields no angle rows (every mapped PMU dropped) needs none.
Linear PMU state estimator¶
LinearPmuStateEstimator.Estimate(model, voltages, currents) solves the rectangular state in one non-iterative
weighted-least-squares step. With x = [Vre_0..Vre_(n-1), Vim_0..Vim_(n-1)] over the n buses of the model, both
phasor kinds are linear in x. A PmuVoltageMeasurement (bus, per-unit phasor, Sigma, CovOffDiag) gives the row
pair Vre_k = z_re, Vim_k = z_im. A PmuCurrentMeasurement (from, to, optional circuit, current into the from end,
Sigma, CovOffDiag) gives, with the branch stamps Yff, Yft:
Ire = gff*Vre_f - bff*Vim_f + gft*Vre_t - bft*Vim_t
Iim = bff*Vre_f + gff*Vim_f + bft*Vre_t + gft*Vim_t (Yff = gff + j*bff, Yft = gft + j*bft)
ys = 1 / (R + jX) bc = j * B/2 a = TapRatio if > 0 else 1 tap = a * exp(j * PhaseShiftDeg * pi/180)
Yff = (ys + bc) / a^2 Yft = -ys / conj(tap)
These are the pi-model stamps of YBus.Build. The branch is the in-service branch with the given from and to ids and,
when Circuit is set, that exact Name, or the ordinal form #k (model branch index k) when no branch is named so;
no match throws InvalidDataException, and two matches without a circuit throw InvalidDataException asking for one.
Each phasor contributes a real and an imaginary row with the 2x2 error covariance
R = [[sRe^2, c], [c, sIm^2]], sRe = Sigma, sIm = SigmaIm (null: Sigma), c = CovOffDiag. Before any row is
built Estimate refuses, naming every problem at once (InvalidDataException): a voltage at a bus the model does not
have, a sigma that is not a finite number above 0, a covariance that is not finite or whose matrix is not positive
definite (determinant sRe^2 * sIm^2 - c^2 at or below 0), and a phasor value that is not finite. Its inverse enters
the normal equations:
R^-1 = [[a, b], [b, d]] with det = sRe^2*sIm^2 - c*c; a = sIm^2/det; b = -c/det; d = sRe^2/det
G = sum over pairs of H^T R^-1 H g = sum over pairs of H^T R^-1 z
x = G^-1 g (explicit Gauss-Jordan inverse, DseLinAlg.Invert, then MatVec)
J = sum over pairs of r^T R^-1 r, r = z - H x
Vm_i = |Vre_i + j*Vim_i| VaDeg_i = atan2(Vim_i, Vre_i) * 180/pi
Converged is false when DseLinAlg.Invert finds the gain singular (a pivot at or below
NumericTolerances.RelativePivotFloor * max|G_ij|, RelativePivotFloor = 1e-12); the result then has zero Vm and
VaDeg and ObjectiveJ = NaN. ObjectiveJ is zero to rounding for a consistent phasor set. The estimator reads the
angles it is given as they are: it has no slack constraint, so the angle frame of its output is the frame of its
input.
Hybrid multi-rate scheduler¶
HybridMultiRateScheduler.Schedule(start, end, scadaPeriod, pmuPeriod) returns the ordered list of
ScheduledUpdate(Time, IsScada) over [start, end] inclusive: a SCADA update at start + k * scadaPeriod and a PMU
update at start + k * pmuPeriod. At a coincident instant only the SCADA update is emitted. A non-positive period
throws ArgumentException; end < start returns an empty list; the times carry start.Kind. The result is a pure
function of its arguments. The class is internal; the intended consumer runs a robust WLS at each SCADA update and the
linear estimator at each PMU update, and no such consumer exists in src/.
Horizon guard, as applied¶
ForwardInferenceGuard.RejectFuture(stamp, utcNow, what) throws ForwardInferenceException when stamp is newer
than utcNow - HorizonHours, with HorizonHours = 169.0 (7 days plus 1 hour;
src/GridSim.Core/Diagnostics/ForwardInferenceGuard.cs). It is applied three times on the PMU path. pmu-ingest
calls it on every decoded DATA frame's time against the real clock before anything is printed or written: one frame
newer than the horizon refuses the whole capture (REJECTED (forward inference), exit 2), live or recorded.
PmuMeasurementSetMapper.Map calls it again on frame.TimeUtc with the label "PMU superframe" before reading
anything else (utcNow defaults to DateTime.UtcNow). A MeasurementSet built by the mapper carries
TakenUtc = frame.TimeUtc, so MeasurementSet.Load (the reader pmu-estimate and estimate use) applies its own
guard when the set is read back. The decoder, the config parser and the PDC do not call it.
Sample quality¶
C37118SampleQuality.Of(frameTimeQuality, stat) gives each PMU block of a DATA frame a C37118SampleVerdict, from
IEEE C37.118.2-2011 as the decoder records it ([VERIFY] on the table numbers):
| Verdict | Rule |
|---|---|
UnreliableFrameTime |
the FRACSEC message time-quality code (low four bits of the top FRACSEC byte) is above 0x5 (time error 100 us or more, an undefined code 0xC to 0xE, or clock failure 0xF); every block of the frame |
InvalidData |
STAT bits 15-14 are not 00 (PMU error, test mode or absent data) |
Unsynchronised |
STAT bit 13 set, a STAT time quality (bits 8-6) of 100 (100 us) or worse, or an unlocked time (bits 5-4) other than 00 |
Usable |
none of the above |
The line is 10 us: a time error dt rotates a 50 Hz phasor by 360 * 50 * dt degrees, 0.18 degrees at 10 us and
1.8 degrees at 100 us, against the 0.57 degrees of angle a 1 % total vector error allows. The other STAT bits (sorting,
trigger, configuration change, data modified) do not decide usability. pmu-ingest aligns and maps only Usable
samples.
The pmu-ingest verb¶
gridsim pmu-ingest (<capture.bin> | --live tcp://host:port|udp://[bind]:port --live-frames N [--live-seconds S]
[--live-idcode ID] [--live-quarantine <dir>])
[--channels <map.json> --measurements-dir <dir>]
The flags are in the CLI reference (pmu-ingest); the result-file flags
(--json, --csv, --out) apply as for other verbs. --channels and --measurements-dir go together (one alone
exits 2). The --live-* flags qualify --live and are refused without it (exit 2), and --live takes no capture file.
Input. A capture file holds frames as sent, back to back: CONFIG-1 or CONFIG-2 frames and DATA frames. A capture
named *.quarantine.c37, or one with a .quarantine.json sidecar beside it, is refused (exit 1): it is a quarantined
live capture and is never read as a recorded one. With --live the receiver
(C37118LiveReceiver) turns a device's stream into the same bytes: over TCP GridSim is the client, sends "send
CONFIG-2" and "turn on transmission" COMMAND frames to --live-idcode (default 1), frames the stream fail-loud and sends
"turn off transmission" when it stops; over UDP it listens on the local port, one frame per datagram, and drops and
counts a datagram that is not exactly one frame. The receive stops after --live-frames DATA frames or after
--live-seconds (default 30, at most 86,400), whichever is first, and prints a live: line with what arrived.
Framing, then decoding. Pass 1 only splits the bytes into frames: at each frame start it requires at least 4 bytes,
0xAA and a FRAMESIZE of at least 16 that fits the remaining bytes, and reads the 14-byte header. A capture that does
not split cleanly is refused whole (exit 1) before any frame is decoded, because nothing says where the next frame
starts. Pass 2 walks the frames in order:
| Frame type | Handling |
|---|---|
| CONFIG-1 or CONFIG-2 | Counted. A bad CRC is counted and the frame skipped. A good frame is parsed; if it differs from the previous configuration in TIME_BASE, PMU count, any IDCODE, FORMAT, channel counts, FNOM or PHUNIT table, the change is counted. A frame that passes its CRC but cannot be parsed (a TIME_BASE of 0 among the refusals) ends the run with exit 1. |
| DATA | Before any usable CONFIG frame: counted, not decoded. Otherwise decoded with the stream overload against the configuration in force when it arrived (the last good CONFIG frame before it). Ok frames are kept with their FRACSEC time quality; CrcMismatch and Malformed (including a FRACSEC not below TIME_BASE) are counted and dropped. An ArgumentException from the decoder ends the run with exit 1. |
| HEADER, COMMAND, CONFIG-3, other | Counted as "other", not decoded. |
With no usable CONFIG frame the run exits 1.
Quarantine. With --live --live-quarantine <dir>, the decoded DATA frames newer than the horizon are held back
first: they are written as received, with the CONFIG frames and every frame that carried no trusted time, to
pmu-live-<received UTC>.quarantine.c37 in the folder, with a .quarantine.json sidecar (schema
gridsim-pmu-quarantine/1) that marks them non-evidential and tags them CuratedPlaceholder, never Recorded. They
are removed from the samples, so no later step sees them. A folder at or below the GDA evidence lake (its
DataSchema.json) is refused before any receive (exit 1). Without the flag nothing is held back.
Horizon. Every remaining decoded DATA frame's time must be older than the horizon against the real clock; one fresh
frame refuses the whole run with REJECTED (forward inference) and exit 2, before anything is printed or written.
Channel map. With --channels, the map (gridsim-pmu-channels/1, PmuChannelMapFile) is loaded before the capture
(a bad map exits 1) and checked against every configuration in force for a decoded DATA frame (ValidateAgainst): every
mapped PMU must be in it, every phasor index inside that PMU's phasor count, and, when the CONFIG frame carries PHUNIT
types, a voltage channel must be a voltage phasor and a current channel a current phasor
(PmuConfig.PhasorIsCurrent). A misfit exits 1 and names the CONFIG frame it fails against. The file:
{ "schema": "gridsim-pmu-channels/1", "case": "case9", "baseMva": 100,
"reference": { "pmu": 101, "phasor": 0, "anchorDeg": 0 },
"pmus": [ { "pmu": 101,
"voltages": [ { "phasor": 0, "bus": 1, "baseVolts": 9526.3, "vmSigmaPu": 0.002, "vaSigmaDeg": 0.1 } ],
"currents": [ { "phasor": 1, "from": 1, "to": 4, "circuit": null, "baseAmps": 2405.6,
"iMagSigmaPu": 0.005, "iAngSigmaDeg": 0.2 } ] } ] }
Every field except case, circuit and anchorDeg is required; a wrong schema, a missing, non-finite or non-positive
base or sigma, a negative phasor index, a PMU listed twice, a phasor mapped twice, no channel at all and a reference that
is not a mapped voltage channel are refused, every problem named.
Quality and alignment. Each block of each kept frame gets its C37118SampleVerdict; only Usable samples go to a
PhasorDataConcentrator built from the IDCODEs of every configuration in force, with a zero wait window and one
FlushAll. A dropped sample leaves its PMU missing at that instant. An instant is incomplete when a PMU of the
configuration in force then did not report usably.
Measurement sets. With the channel map, every aligned instant goes through PmuMeasurementSetMapper.Map (the
horizon again) and is written with SaveWithoutOverwrite as pmu-<yyyyMMddTHHmmssfffffffZ>.json in the folder (a
clash gets a -dupN name). An instant whose reference channel did not report usably, or with no mapped PMU, writes no
set and is counted.
Report. A banner with the byte count and [EXPERIMENTAL]; with --live, the live: line first; the frame-count
line (CONFIG and bad-CRC CONFIG; DATA decoded, bad CRC, malformed, before any CONFIG; other); with the quarantine, a
quarantine: line; the configuration line (PMU count, TIME_BASE, DATA_RATE of the last configuration, and, when it
changed, the count and that each frame was decoded with the configuration in force); one row per PMU of every
configuration in force (| PMU | IDCODE | phasors | samples | first (UTC) | last (UTC) | freq min Hz | freq max Hz |
STAT non-zero | dropped |, a phasor count that changed printed as 2/3); the aligned instants and how many have a PMU
missing; the quality: line with the dropped samples by reason; with the channel map, the measurement sets: line.
Result files. summary holds file, frames, config_frames, data_decoded, data_bad_crc, data_malformed,
data_before_config, other_frames, pmus, time_base, data_rate, config_changes, aligned_instants,
incomplete_instants, samples_dropped, dropped_invalid_data, dropped_unsynchronised,
dropped_unreliable_frame_time; with the quarantine quarantined_data_frames; with the channel map
measurement_sets_written, instants_without_reference, instants_without_mapped_pmu. The samples table holds one
row per phasor of every decoded block: time_utc (seven fractional digits), pmu_idcode, station, stat, freq_hz,
rocof_hz_per_s, phasor, real, imag, magnitude, angle_deg at full double precision, and quality (the
verdict).
Exit codes.
| Code | Meaning |
|---|---|
| 0 | Every DATA frame decoded, no sample dropped, every instant with a reference wrote its set, and a live receive was complete. |
| 1 | An input error: the file cannot be read or is a quarantine capture; it does not frame; a header is unreadable; a CONFIG frame passed its CRC but cannot be parsed; the decoder refused the configuration; no usable CONFIG frame; a bad or misfitting channel map; the output folder cannot be created; a live address or transport failure; a quarantine folder inside the evidence lake or a quarantine write failure. |
| 2 | Usage (arguments, flags, the flag pairs above) or REJECTED (forward inference). |
| 3 | DATA frames with a bad CRC, malformed or before any CONFIG; samples dropped by the quality rule; instants without a usable reference channel; an incomplete live receive (datagrams that were not one frame, or the timeout before the frames asked for); a live receive with no frame. The summary still prints and stderr names each count. |
The pmu-estimate verb¶
gridsim pmu-estimate <case> --measurements <set.json> [--json|--csv|--out <file>] runs the linear PMU estimate on one
gridsim-measurements/1 set (pmu-estimate). The set is loaded
(MeasurementSet.Load, so the horizon guard applies; a fresh set is REJECTED, exit 2) and validated against the case
(exit 1 on an input error).
PmuMeasurementSetAdapter.FromSet(set, model) pairs the polar rows into rectangular phasors: a bus's VmPu and VaDeg
rows form its voltage phasor, and a branch end's IMagPu and IAngDeg rows (same from, to, end and circuit) its
current phasor. Every other row, and a magnitude or angle without its partner, is listed as not used with the reason.
Two rows of one kind at one place (ambiguous pairing), a current metered at the To end (the linear model holds the
from-end current only) and a phasor of magnitude 0 are refused (exit 1). The error model is the first-order
(linearised) transform of the polar sigmas at the measured value: with z = m e^(j theta), s_m in pu and s_theta
in radians,
var_re = c^2 s_m^2 + m^2 s^2 s_theta^2 var_im = s^2 s_m^2 + m^2 c^2 s_theta^2
cov = c s (s_m^2 - m^2 s_theta^2) c = cos theta, s = sin theta (DetMath sine and cosine)
Its determinant is m^2 s_m^2 s_theta^2, positive for m > 0, so each pair is weighted by its full 2x2 inverse
(Sigma = sqrt(var_re), SigmaIm = sqrt(var_im), CovOffDiag = cov).
A set with no voltage or current phasor exits 1. A gain that is singular (the phasors do not determine every bus
voltage) prints NOT OBSERVABLE and exits 3. Otherwise the verb prints the phasors used and not used, J over
2 * (voltages + currents) - 2n degrees of freedom (one non-iterative solve, no bad-data test) and one row per bus
(|V| to six decimals, angle to four), exit 0. The result file holds case, measurements, voltage_phasors,
current_phasors, rows_not_used, objective_j, degrees_of_freedom, a buses table (bus_id, vm_pu, va_deg)
and a not_used table. A set written by pmu-ingest is read by pmu-estimate directly.
Parameters and defaults¶
| Name | Value | Unit | Meaning | Source symbol |
|---|---|---|---|---|
| SYNC byte 0 | 0xAA |
- | Start of every frame. | C37118CommonHeader.Parse |
| Common header | 14 | bytes | Header length. | C37118CommonHeader.Parse |
| Smallest CONFIG frame | 24 | bytes | Header, TIME_BASE, NUM_PMU, DATA_RATE, CHK with no PMU. | C37118ConfigParser.MinimumConfigFrameBytes |
Smallest frame pmu-ingest splits |
16 | bytes | Floor on FRAMESIZE when framing a file. | RunPmuIngest in src/GridSim.Cli/Program.cs |
| CRC polynomial, initial value | 0x1021, 0xFFFF |
- | CRC-CCITT, no reflection, no final XOR. | C37118Crc.Compute |
| FRACSEC and TIME_BASE mask | 0x00FFFFFF |
- | Counts of 1/TIME_BASE s; top FRACSEC byte is time quality. | C37118CommonHeader.Parse, C37118ConfigParser.Parse |
| FORMAT mask | 0x0F |
- | Four defined bits. | C37118ConfigParser.Parse |
| PHUNIT scale | (PHUNIT & 0x00FFFFFF) / 100000 |
V or A per count | Integer phasor scale. | PmuConfig.PhasorScale |
| Integer polar angle | 1e-4 |
rad per count | Signed 16-bit angle. | C37118Decoder.ReadBlock |
| Integer FREQ | NominalHz + count / 1000 |
Hz | Deviation from FNOM in millihertz. | C37118Decoder.ReadBlock |
| Integer DFREQ | count / 100 |
Hz/s | ROCOF times 100. | C37118Decoder.ReadBlock |
| Nominal frequency | 50 if FNOM bit 0 is set, else 60 | Hz | PmuConfig.NominalHz. |
PmuConfig.Is50Hz |
| PDC wait window | constructor argument; pmu-ingest passes TimeSpan.Zero |
s | Delay past an instant before emitting it. | PhasorDataConcentrator |
| Horizon | 169 | h | Newest age pmu-ingest, the mapper and MeasurementSet.Load admit. |
ForwardInferenceGuard.HorizonHours |
| Largest TIME_BASE | 2^24 - 1 |
counts per second | A 24-bit field; 0 is refused. | C37118Decoder.MaxTimeBase |
| Usable frame time quality | 0x0 to 0x5 |
code | Time within 10 us of UTC. | C37118SampleQuality.MaxUsableFrameTimeQualityCode |
| Usable STAT time quality | 000 to 011 |
code | Within 10 us (000 means not used). |
C37118SampleQuality.MaxUsableStatTimeQuality |
| Live receive time limit | 30, at most 86,400 | s | --live-seconds. |
ReceiveLivePmu in Program.cs |
| Live COMMAND IDCODE | 1 | - | --live-idcode, TCP only. |
ReceiveLivePmu in Program.cs |
| Quarantine tag | CuratedPlaceholder |
- | Provenance of every quarantined frame. | PmuLiveQuarantine.Tag |
Mapper baseMva, caseName |
100.0, "pmu" |
MVA | Fields of the built set. | PmuMeasurementSetMapper.Map |
PmuAngleReference.AnchorDeg |
0 | deg | Slack-frame angle of the reference bus. | PmuAngleReference |
| Estimator pivot floor | 1e-12 * max|G_ij| |
- | Singular-gain test. | NumericTolerances.RelativePivotFloor |
Invariants¶
| Invariant | Test |
|---|---|
CRC-CCITT of "123456789" is 0x29B1. |
C37118DecoderTests.Crc_ccitt_matches_the_standard_check_vector |
A flipped byte fails the CRC, and the decoder reports CrcMismatch. |
C37118DecoderTests.A_flipped_byte_fails_the_crc, N061PmuIngestTests.A_frame_whose_length_does_not_match_the_configuration_is_malformed_and_a_bad_crc_is_reported |
| A float DATA frame round-trips: time, phasors, FREQ, DFREQ. | C37118DecoderTests.A_data_frame_round_trips_through_the_decoder |
The FORMAT word, written as a raw number, decodes by the standard bit positions (including 0x000E). |
C37118FormatBitOrderTests.The_raw_format_word_decodes_by_the_standard_bit_positions, C37118FormatBitOrderTests.A_float_rectangular_frame_decodes_correctly_with_the_parsed_format |
| The parser reads TIME_BASE, DATA_RATE and each PMU's FORMAT. | C37118ConfigParserTests.Parses_time_base_data_rate_and_per_pmu_format |
| The parser keeps PHUNIT and FNOM verbatim. | C37118IntegerScalingTests.The_config_parser_keeps_phunit_and_fnom |
| Integer rectangular and polar phasors and integer FREQ/DFREQ are scaled from the parsed configuration (50 and 60 Hz, unsigned polar magnitude, angle in 1e-4 rad). | C37118IntegerScalingTests.Integer_rectangular_phasors_and_frequency_are_scaled_from_the_parsed_config, C37118IntegerScalingTests.Integer_polar_phasors_scale_the_unsigned_magnitude_and_keep_the_angle_in_1e4_rad_at_60_hz, C37118FixedFormatTests.Decodes_a_fixed_integer_rectangular_data_frame |
| Float data decodes identically through either overload. | C37118IntegerScalingTests.Float_data_decodes_the_same_through_either_overload |
| An integer-phasor configuration without a PHUNIT table is refused, never returned as raw counts. | C37118IntegerScalingTests.An_integer_phasor_config_without_phunit_is_refused, N061PmuIngestTests.Integer_phasors_without_a_phunit_table_are_refused |
| The frame-length check counts analogue channels. | C37118IntegerScalingTests.The_frame_length_check_counts_the_analogue_channels |
| A multi-PMU frame decodes every block in CONFIG order, with each block's own STAT and scaling. | N061PmuIngestTests.A_two_pmu_frame_decodes_both_blocks_in_config_order |
| A one-PMU stream decodes bit for bit as the single-PMU overload (time, STAT, frequency, ROCOF, every phasor component). | N061PmuIngestTests.A_one_pmu_stream_decodes_bit_for_bit_as_the_single_pmu_overload |
A frame whose length does not match the configuration is Malformed. |
N061PmuIngestTests.A_frame_whose_length_does_not_match_the_configuration_is_malformed_and_a_bad_crc_is_reported |
| A good CONFIG frame parses every field; bytes after the declared frame are ignored. | N163C37118ConfigParserRefusalTests.A_good_frame_parses_every_field_as_before, N163C37118ConfigParserRefusalTests.Bytes_after_the_declared_frame_are_ignored_as_before |
A CONFIG frame with a wrong CRC, a corrupted body byte, a truncation, a FRAMESIZE below the minimum, a phasor or PMU count that runs past the frame, or no room for DATA_RATE is refused with a named C37118FrameException, never a raw range exception. |
N163C37118ConfigParserRefusalTests.A_wrong_crc_is_refused_naming_both_check_words, A_corrupted_body_byte_is_refused_by_the_crc, A_frame_cut_short_of_its_declared_size_is_refused_as_truncated, A_declared_size_below_the_smallest_config_frame_is_refused, A_phasor_count_that_runs_past_the_frame_is_refused_by_name_not_by_a_raw_range_exception, A_pmu_count_larger_than_the_blocks_present_is_refused_naming_the_missing_block, A_frame_with_no_room_for_data_rate_is_refused |
C37118FrameException is an ArgumentException. |
N163C37118ConfigParserRefusalTests.The_named_error_is_still_an_argument_exception_for_existing_callers |
| The PDC aligns PMUs onto common instants in time order. | PhasorDataConcentratorTests.Aligns_pmus_onto_common_instants_in_time_order |
| A PMU that did not report is listed as dropped. | PhasorDataConcentratorTests.Flags_a_missing_pmu_as_dropped |
| An instant is held until it falls behind the wait window. | PhasorDataConcentratorTests.Holds_an_instant_until_it_falls_behind_the_wait_window |
| A straggler for an emitted instant is discarded and counted, never re-emitted; later instants still flow. | C37118FormatBitOrderTests.A_straggler_for_a_flushed_instant_is_discarded_and_counted_not_re_emitted, C37118FormatBitOrderTests.Instants_after_the_high_water_mark_still_flow_normally |
The mapper builds per-unit voltage and current rows at the right bus and branch, with TakenUtc from the superframe. |
PmuMeasurementSetMapperTests.Maps_voltage_and_current_phasors_to_measurement_rows |
| A dropped PMU contributes no rows. | PmuMeasurementSetMapperTests.A_dropped_pmu_contributes_no_rows |
| A superframe newer than the horizon is refused, including when no angle reference is given. | PmuMeasurementSetMapperTests.A_fresh_superframe_is_refused_by_the_guard, PmuAngleReferenceTests.The_horizon_guard_still_runs_first |
| Angles are differences from the reference channel; a common UTC rotation cancels; the difference wraps into +/-180 degrees; the anchor moves a non-slack reference into the slack frame. | PmuAngleReferenceTests.Angles_are_differences_from_the_reference_channel, A_common_utc_frame_rotation_cancels, The_difference_is_wrapped_into_plus_minus_180_degrees, The_anchor_puts_a_non_slack_reference_into_the_slack_frame |
| No angle row is emitted without a reference: no named reference, a dropped reference PMU and an unmapped or non-voltage reference all fail loud. | PmuAngleReferenceTests.No_named_reference_is_refused_when_there_are_angles_to_emit, A_dropped_reference_pmu_is_refused_not_emitted_absolute, A_reference_that_is_not_a_mapped_voltage_channel_is_refused |
A full consistent phasor set recovers the solved truth of case9 in one linear solve (ObjectiveJ < 1e-10, Vm to 1e-9, VaDeg to 1e-7); a voltage-only set with correlated covariance recovers Vm to 1e-9 (its angles and ObjectiveJ are not asserted). |
LinearPmuStateEstimatorTests.Full_phasor_set_recovers_the_truth_in_one_linear_solve, LinearPmuStateEstimatorTests.Correlated_measurement_covariance_still_recovers_the_truth |
| A voltage-only set on case14 recovers every magnitude in one solve; current phasors anchor the angles of unmeasured buses. | LinearPmuStateEstimatorTests.A_voltage_only_set_on_case14_recovers_every_magnitude_in_one_solve, Recovers_angles_at_unmeasured_buses_from_current_anchoring |
| The estimator refuses an unknown bus, an unusable sigma or covariance and a non-finite value, naming every problem. | Doc15SynchrophasorTests.D109_a_voltage_at_a_bus_the_model_does_not_have_is_refused_by_name, D109_an_unusable_sigma_or_covariance_is_refused_by_name, D109_a_non_finite_value_is_refused_and_every_problem_is_named_at_once |
| Frame times are exact integer ticks, floored; TIME_BASE 0 is refused; a FRACSEC not below TIME_BASE is malformed. | Doc15SynchrophasorTests.D110_instants_the_double_put_one_tick_short_are_now_exact, D110_the_fraction_is_floored_to_whole_ticks, D111_a_config_frame_with_time_base_0_is_refused_by_name, D111_every_decode_overload_refuses_an_impossible_time_base, D111_a_fracsec_not_below_time_base_makes_the_frame_malformed |
| The mapper bounds-checks every channel and names every misfit. | Doc15SynchrophasorTests.D008_a_non_reference_channel_past_the_reported_phasors_is_refused_by_name, D008_every_bad_channel_is_named_at_once_and_a_pmu_that_did_not_report_is_not_checked |
pmu-ingest refuses a fresh capture; drops invalid and unsynchronised samples and exits 3; writes one guarded set per usable instant; refuses a misfitting or lone map; checks the map against every configuration; frames before decoding. |
Doc3CliHorizonProcessTests.D107_pmu_ingest_refuses_a_capture_with_a_frame_newer_than_the_horizon, D108_an_invalid_or_unsynchronised_sample_is_dropped_counted_and_exits_3, D108_a_good_sample_with_informational_flags_is_used, D107_pmu_ingest_with_a_channel_map_writes_one_guarded_measurement_set_per_usable_instant, D107_pmu_ingest_refuses_a_channel_map_that_does_not_fit_or_is_given_alone, D107_an_instant_whose_reference_channel_was_dropped_writes_no_set_and_exits_3; Doc15PmuIngestProcessTests.D008_a_map_that_fits_only_the_later_configuration_is_refused_naming_the_earlier_one, D223_a_capture_that_does_not_frame_is_refused_before_any_frame_is_decoded |
| Live receive over TCP stops at the frames asked for, refuses bad framing, keeps a bad-CRC frame for the pipeline, reports a timeout; the live run uses the recorded pipeline. | Cf5LiveC37118Tests |
The quarantine keeps frames byte for byte, never overwrites, is tagged below Recorded, refuses the evidence lake, and a quarantine capture is refused as a recorded capture. |
Cf6LivePmuQuarantineTests |
pmu-estimate: the adapter pairs rows and lists the rest, the covariance is the first-order polar transform exactly, ambiguous and to-end rows are refused, exact phasors recover the solved state, a set written by pmu-ingest is read. |
PmuEstimateTests |
| The scheduler interleaves SCADA and PMU updates in strictly increasing time, SCADA winning a coincident instant (5 s and 0.5 s over 5 s: 2 SCADA and 9 PMU updates). | HybridMultiRateSchedulerTests.Interleaves_scada_and_pmu_with_nine_pmu_per_scada_cycle |
pmu-ingest decodes a clean capture and exits 0; counts bad frames and exits 3; refuses an unframeable or CONFIG-less file with exit 1 and a missing argument with exit 2. |
N061PmuIngestTests.Pmu_ingest_decodes_a_clean_capture_and_exits_0, Pmu_ingest_counts_bad_frames_and_exits_3, Pmu_ingest_refuses_a_file_it_cannot_frame_or_with_no_config |
pmu-ingest writes the shared result files. |
CliResultWriterTests (the verb is in its coverage list) |
| The experimental markers are present in the PDC, mapper and estimator sources. | R062EstimationWiringTests.Stale_docs_are_corrected_and_test_only_classes_are_marked_experimental |
The decode and alignment path is single-threaded scalar arithmetic with no parallel step. The linear estimator is
tested against a tolerance, not bit for bit, and no test compares its output across CPU tiers. The inverse kernel it
uses, DseLinAlg.Invert, has its own bit-identity test (KalmanDenseKernelBitIdentityTests).
Worked example¶
The example uses the two-PMU stream of N061PmuIngestTests. Its frames are rebuilt byte for byte by
docs/_captures/c37118/make-captures.py into docs/_captures/c37118/two-pmu.bin: a CONFIG-2 frame and three DATA
frames 20 ms apart. STN_A (IDCODE 101) sends two float rectangular phasors with float FREQ/DFREQ (FORMAT 0x000A).
STN_B (IDCODE 102) sends one integer rectangular phasor, one analogue value and one digital word, with PHUNIT
1,000,000, which is 10 V per count.
The CONFIG frame, by hand¶
The CONFIG frame is 424 bytes. Its leading fields and the two PMU headers read:
AA 31 01 A8 00 07 65 53 F1 00 00 00 00 00 common header
00 0F 42 40 00 02 TIME_BASE, NUM_PMU
"STN_A" + 11 spaces 00 65 00 0A 00 02 00 00 00 00 STN, IDCODE, FORMAT, PHNMR, ANNMR, DGNMR
"STN_B" + 11 spaces 00 66 00 00 00 01 00 01 00 01 STN, IDCODE, FORMAT, PHNMR, ANNMR, DGNMR
AA 31: SYNC0xAA;0x31is type 3 (Config2), version 1.01 A8is FRAMESIZE 424;00 07is IDCODE 7;65 53 F1 00is SOC 1,700,000,000; FRACSEC is 0.00 0F 42 40is TIME_BASE 1,000,000;00 02is NUM_PMU 2.STN_A: IDCODE00 65is 101; FORMAT00 0Ais binary1010, soPhasorFloat(bit 1) andFreqFloat(bit 3), rectangular; PHNMR 2, no analogues, no digital words. Its PHUNIT words are00 00 00 00and01 00 00 00(a current channel, factor 0), which float data never uses.STN_B: IDCODE00 66is 102; FORMAT00 00is all integer, rectangular; PHNMR 1, ANNMR 1, DGNMR 1. Its PHUNIT word is00 0F 42 40(voltage, factor 1,000,000 times 1e-5 gives 10 V per count). FNOM is00 01for both PMUs (bit 0 set: 50 Hz).- The last four bytes are DATA_RATE
00 32(50) and CHK57 26.
The first DATA frame, by hand¶
The first DATA frame is 56 bytes: 14 + (2 + 2*8 + 8) + (2 + 4 + 4 + 2 + 2) + 2.
AA 01 00 38 00 07 65 53 F1 01 00 00 00 00 common header
00 00 STN_A STAT
48 60 9C 00 C4 7A 00 00 phasor 0: real, imag (float32)
42 F1 00 00 41 F2 00 00 phasor 1: real, imag (float32)
42 48 0A 3D BC A3 D7 0A FREQ, DFREQ (float32)
80 00 STN_B STAT
08 FC FF 9C phasor 0: real, imag (int16)
00 0F FF FC FREQ, DFREQ (int16)
00 00 00 00 analogue, digital word (stepped over)
4D 2A CHK
- Header:
0x01is type 0 (Data), version 1;00 38is 56 bytes. SOC is0x6553F101= 1,700,000,001 s, which is 2023-11-14T22:13:21Z; FRACSEC is 0, soTimeUtcis that instant exactly. STN_A, float phasor 0:48 60 9C 00is 230000.0 andC4 7A 00 00is -1000.0. Check the second by hand: sign 1, exponent0x88 = 136, so the scale is2^(136-127) = 512, mantissa0x7A0000 / 2^23 = 0.953125, and-1.953125 * 512 = -1000. Phasor 1 is42 F1 00 00= 120.5 and41 F2 00 00= 30.25. Then|V0| = sqrt(230000^2 + 1000^2) = 230002.17...andangle = atan2(-1000, 230000) = -0.2491...degrees.STN_A, FREQ42 48 0A 3D: the float32 nearest 50.01, which as a double is 50.0099983215332. DFREQBC A3 D7 0Ais the float32 nearest -0.02.STN_B: STAT80 00is0x8000(32768). Phasor 0 is08 FC= 2300 counts andFF 9C= -100 counts; scale1,000,000 / 100000 = 10, so the phasor is23000 - j1000volts. FREQ00 0F= 15, so50 + 15/1000 = 50.015Hz; DFREQFF FC= -4, so-4/100 = -0.04Hz/s.CHK = 0x4D2Ais the CRC-CCITT of the 54 bytes before it; the decoder accepts the frame.
The frame length is exactly what the configuration demands, so the stream overload returns Ok.
The captured run¶
The command and its output are in docs/_captures/c37118/pmu-ingest-clean.txt. Timings do not appear in the output,
but the timestamps and the byte count are those of the capture file and differ for a different capture.
$ gridsim pmu-ingest docs/_captures/c37118/two-pmu.bin
PMU INGEST - docs/_captures/c37118/two-pmu.bin [IEEE C37.118.2 capture, 592 bytes] [EXPERIMENTAL]
4 frame(s): 1 CONFIG (0 bad CRC), 3 DATA (3 decoded, 0 bad CRC, 0 malformed, 0 before any CONFIG), 0 other
configuration: 2 PMU(s), TIME_BASE 1000000, DATA_RATE 50
| PMU | IDCODE | phasors | samples | first (UTC) | last (UTC) | freq min Hz | freq max Hz | STAT non-zero | dropped |
|-----|--------|---------|---------|-------------|------------|-------------|-------------|---------------|---------|
| STN_A | 101 | 2 | 3 | 2023-11-14T22:13:21.000000Z | 2023-11-14T22:13:21.040000Z | 50.0100 | 50.0100 | 0 | 0 |
| STN_B | 102 | 1 | 3 | 2023-11-14T22:13:21.000000Z | 2023-11-14T22:13:21.040000Z | 50.0150 | 50.0150 | 3 | 3 |
aligned instants: 3 (3 with a PMU missing)
quality: 3 sample(s) dropped and not used (3 invalid data (STAT bits 15-14), 0 unsynchronised (STAT bit 13, time quality or unlocked time), 0 with an unreliable frame time (FRACSEC time quality))
pmu-ingest: 3 PMU sample(s) dropped by the STAT / time-quality rule (3 invalid data, 0 unsynchronised, 0 unreliable frame time); they are counted, not used.
[exit 3]
The file is 592 bytes: 424 for the CONFIG frame and 3 * 56 = 168 for the DATA frames. The three DATA frames are
20 ms apart (FRACSEC counts 0, 20,000 and 40,000 at TIME_BASE 1,000,000), which gives the first and last times.
STN_B shows 3 non-zero STAT words because its STAT is 0x8000 in every frame: STAT bits 15-14 are 10 (test mode
or absent data), so the quality rule gives each STN_B sample the verdict InvalidData, drops it from the alignment
and the run exits 3 with the dropped column and the quality: line counting the three samples.
The decoded values¶
--csv writes every phasor of every decoded block at full precision, with the sample's quality verdict in the last
column; a dropped sample is listed with its verdict, not left out. The run is captured in
docs/_captures/c37118/pmu-ingest-csv.txt, and the table is docs/_captures/c37118/samples.csv.
$ gridsim pmu-ingest docs/_captures/c37118/two-pmu.bin --csv docs/_captures/c37118/samples.csv
PMU INGEST - docs/_captures/c37118/two-pmu.bin [IEEE C37.118.2 capture, 592 bytes] [EXPERIMENTAL]
4 frame(s): 1 CONFIG (0 bad CRC), 3 DATA (3 decoded, 0 bad CRC, 0 malformed, 0 before any CONFIG), 0 other
...
quality: 3 sample(s) dropped and not used (3 invalid data (STAT bits 15-14), 0 unsynchronised (STAT bit 13, time quality or unlocked time), 0 with an unreliable frame time (FRACSEC time quality))
pmu-ingest: 3 PMU sample(s) dropped by the STAT / time-quality rule (3 invalid data, 0 unsynchronised, 0 unreliable frame time); they are counted, not used.
[pmu-ingest] wrote docs\_captures\c37118\samples.csv, docs\_captures\c37118\samples-summary.csv
[exit 3]
The first three rows of samples.csv, the first DATA frame:
time_utc,pmu_idcode,station,stat,freq_hz,rocof_hz_per_s,phasor,real,imag,magnitude,angle_deg,quality
2023-11-14T22:13:21.0000000Z,101,STN_A,0,50.0099983215332,-0.019999999552965164,0,230000,-1000,230002.1739027699,-0.2491105151529136,Usable
2023-11-14T22:13:21.0000000Z,101,STN_A,0,50.0099983215332,-0.019999999552965164,1,120.5,30.25,124.23893310874816,14.092169052423335,Usable
2023-11-14T22:13:21.0000000Z,102,STN_B,32768,50.015,-0.04,0,23000,-1000,23021.728866442678,-2.489552921999156,InvalidData
These match the hand decode: STN_A phasor 0 is 230000 - j1000 V with magnitude 230002.1739... V and angle
-0.2491... degrees; STN_B phasor 0 is 23000 - j1000 V (2300 and -100 counts at 10 V per count), frequency 50.015 Hz
and ROCOF -0.04 Hz/s. The float32 quantisation of STN_A shows in the frequency 50.0099983215332 and the ROCOF
-0.019999999552965164, which are exactly the stored float32 values converted to double.
A damaged capture¶
docs/_captures/c37118/two-pmu-damaged.bin is the same stream with a DATA frame in front of the CONFIG frame and a
flipped byte in its last DATA frame. The run is captured in docs/_captures/c37118/pmu-ingest-damaged.txt.
$ gridsim pmu-ingest docs/_captures/c37118/two-pmu-damaged.bin
PMU INGEST - docs/_captures/c37118/two-pmu-damaged.bin [IEEE C37.118.2 capture, 648 bytes] [EXPERIMENTAL]
5 frame(s): 1 CONFIG (0 bad CRC), 4 DATA (2 decoded, 1 bad CRC, 0 malformed, 1 before any CONFIG), 0 other
configuration: 2 PMU(s), TIME_BASE 1000000, DATA_RATE 50
| PMU | IDCODE | phasors | samples | first (UTC) | last (UTC) | freq min Hz | freq max Hz | STAT non-zero | dropped |
|-----|--------|---------|---------|-------------|------------|-------------|-------------|---------------|---------|
| STN_A | 101 | 2 | 2 | 2023-11-14T22:13:21.000000Z | 2023-11-14T22:13:21.020000Z | 50.0100 | 50.0100 | 0 | 0 |
| STN_B | 102 | 1 | 2 | 2023-11-14T22:13:21.000000Z | 2023-11-14T22:13:21.020000Z | 50.0150 | 50.0150 | 2 | 2 |
aligned instants: 2 (2 with a PMU missing)
quality: 2 sample(s) dropped and not used (2 invalid data (STAT bits 15-14), 0 unsynchronised (STAT bit 13, time quality or unlocked time), 0 with an unreliable frame time (FRACSEC time quality))
pmu-ingest: 2 DATA frame(s) could not be decoded (1 bad CRC, 0 malformed, 1 before any CONFIG); they are counted, not used.
pmu-ingest: 2 PMU sample(s) dropped by the STAT / time-quality rule (2 invalid data, 0 unsynchronised, 0 unreliable frame time); they are counted, not used.
[exit 3]
The two bad frames are counted and left out of the table; the two good frames are decoded and aligned, and their
STN_B samples are dropped by the quality rule as in the first run; the exit code is 3. A bad frame never produces a zeroed or partial sample.
A truncated capture¶
docs/_captures/c37118/two-pmu-truncated.bin is two-pmu.bin with its last 10 bytes cut off. The run is
captured in docs/_captures/c37118/pmu-ingest-truncated.txt.
$ gridsim pmu-ingest docs/_captures/c37118/two-pmu-truncated.bin
pmu-ingest: docs/_captures/c37118/two-pmu-truncated.bin: the frame at byte 536 declares 56 bytes but only 46 remain; the capture is truncated or corrupt.
[exit 1]
The two frames before the bad one are read and then discarded; the file is refused and no summary prints. The usage case, captured in
docs/_captures/c37118/pmu-ingest-usage.txt:
$ gridsim pmu-ingest
usage: gridsim pmu-ingest (<capture.bin> | --live tcp://host:port|udp://[bind]:port --live-frames N [--live-seconds S] [--live-idcode ID] [--live-quarantine <dir>])
[--channels <map.json> --measurements-dir <dir>] (an IEEE C37.118.2 stream: CONFIG then DATA frames)
[exit 2]
The mapper on the same numbers¶
pmu-ingest runs the mapper when given a channel map; this step is arithmetic on the fixture of
PmuMeasurementSetMapperTests.Maps_voltage_and_current_phasors_to_measurement_rows. PMU 1 reports a voltage
phasor of 102 V at 0.1 rad, mapped to bus 4 with BaseVolts = 100, and a current of 5 A at -0.2 rad on branch 4 to
5 with BaseAmps = 10. PMU 2 reports 100 V at 0.05 rad at bus 1 and is the named reference
(PmuAngleReference(PmuId: 2, PhasorIndex: 0)). The rows are:
bus 4 VmPu = 102 / 100 = 1.02
bus 4 VaDeg = (0.1 - 0.05) rad = 0.05 * 180/pi = 2.8648 degrees
4->5 IMagPu = 5 / 10 = 0.5
4->5 IAngDeg = (-0.2 - 0.05) rad = -0.25 * 180/pi = -14.3239 degrees
bus 1 VaDeg = 0 (the reference against itself)
The test asserts these to six decimals, the reference row to exactly 0, and asserts TakenUtc equals the superframe time, 174 hours before its utcNow
(HorizonHours + 5). The same superframe stamped 1 hour before utcNow raises ForwardInferenceException.
Source map¶
| File | Type | Responsibility |
|---|---|---|
src/GridSim.Core/Synchrophasor/C37118Frame.cs |
C37118FrameType, C37118DataFormat, Phasor, C37118CommonHeader, C37118Crc |
Frame type and FORMAT enums, rectangular phasor value, 14-byte header parse, CRC-CCITT. |
src/GridSim.Core/Synchrophasor/C37118ConfigFrame.cs |
PmuConfig, C37118ConfigFrame, C37118FrameException, C37118ConfigParser |
CONFIG-1/2 parse with ordered, bounded refusals; PHUNIT scale and channel type; FNOM. |
src/GridSim.Core/Synchrophasor/C37118Decoder.cs |
C37118Decoder, FrameDecodeStatus, C37118DataFrame, C37118PmuBlock, C37118StreamDataFrame |
CRC-first DATA decode, three overloads, block sizing, integer scaling, time conversion. |
src/GridSim.Core/Synchrophasor/PhasorDataConcentrator.cs |
PmuSample, PdcSuperframe, PhasorDataConcentrator |
Alignment on reporting instants, wait window, dropouts, late-sample counting. |
src/GridSim.Core/Synchrophasor/PmuMeasurementSetMapper.cs |
PmuVoltageChannel, PmuCurrentChannel, PmuChannelMap, PmuAngleReference, PmuMeasurementSetMapper |
Superframe to MeasurementSet rows, angle re-referencing, horizon guard. |
src/GridSim.Core/Estimation/LinearPmuStateEstimator.cs |
PmuVoltageMeasurement, PmuCurrentMeasurement, LinearPmuResult, LinearPmuStateEstimator |
One-shot rectangular WLS from voltage and current phasors. |
src/GridSim.Core/Estimation/Dse/KalmanStateEstimator.cs |
DseLinAlg |
Invert and MatVec used by the linear estimator. |
src/GridSim.Core/Synchrophasor/C37118SampleQuality.cs |
C37118SampleQuality, C37118SampleVerdict |
The STAT and time-quality rule. |
src/GridSim.Core/Synchrophasor/PmuChannelMapFile.cs |
PmuChannelMapFile, PmuChannelMapSet |
gridsim-pmu-channels/1 load and the check against a CONFIG frame. |
src/GridSim.Core/Synchrophasor/C37118LiveTransport.cs |
C37118LiveReceiver, C37118StreamFramer, C37118FrameCheck, the COMMAND frame |
Live TCP and UDP receive. |
src/GridSim.Core/Synchrophasor/PmuLiveQuarantine.cs |
PmuLiveQuarantine |
The live quarantine capture and sidecar. |
src/GridSim.Core/Estimation/PmuMeasurementSetAdapter.cs |
PmuMeasurementSetAdapter, PmuPhasorSet |
Polar set rows to rectangular phasors with the first-order covariance. |
src/GridSim.Core/Simulation/HybridMultiRateScheduler.cs |
ScheduledUpdate, HybridMultiRateScheduler (internal) |
Interleaved SCADA and PMU update schedule. |
src/GridSim.Core/Diagnostics/ForwardInferenceGuard.cs |
ForwardInferenceGuard, ForwardInferenceException |
The 169 h horizon. |
src/GridSim.Cli/Program.cs |
RunPmuIngest, ReceiveLivePmu, RunPmuEstimate |
The two verbs: framing, per-frame handling, quality, alignment, sets, report, result files, exit codes. |
src/GridSim.Cli/CliResultWriter.cs |
verb list | Registers pmu-ingest among the verbs that write result files. |
tests/GridSim.Tests/C37118*Tests.cs, N061PmuIngestTests.cs, N163C37118ConfigParserRefusalTests.cs, PhasorDataConcentratorTests.cs, PmuAngleReferenceTests.cs, PmuMeasurementSetMapperTests.cs, LinearPmuStateEstimatorTests.cs, HybridMultiRateSchedulerTests.cs |
tests | The invariants above. |
docs/_captures/c37118/ |
captures | make-captures.py, the three capture inputs and the captured runs. |
Limits¶
- The Synchrophasor types, the linear estimator and the scheduler are experimental (R062, R095): no resident engine runs the PMU path, and the scheduler has no consumer.
- A device streaming the present is refused by the horizon (or, with
--live-quarantine, captured and not processed); only a PDC or historian replaying history older than 169 h is ingested live. - The linear estimator forms a dense
2n x 2ngain and an explicit inverse, has no slack constraint and no bad-data test, holds the from-end current only, and is tested to tolerance (1e-9onVm), not bit for bit. The adapter's covariance is a first-order linearisation at the measured value. - The scheduler does not check mixed
DateTimeKindvalues among its arguments. - Analogue and digital channels are sized and skipped, not decoded; channel names,
ANUNITandDIGUNITare skipped; HEADER, COMMAND and CONFIG-3 frames are counted and not decoded. - The frame version in SYNC byte 1 is read and not validated.
- The IEEE C37.118.2-2011 text is not in
docs/reference; the table numbers cited in the code comments are as earlier work recorded them and carry the provenance tag[VERIFY].
See also¶
- Design record: IEEE C37.118.2 Synchrophasor Ingest + Phasor Data Concentrator
pmu-ingestin the CLI reference- Ch. 6, State estimation
- Ch. 18, Real-time distribution and dynamic estimation
- Ch. 20, RTU/SCADA interposer, the RTU counterpart of the mapper
- Ch. 8, Provenance and invariants, the horizon guard
- Ch. 10, File and wire formats
- Ch. 21 (manual), State estimation