Skip to content

Commit ca2bb0e

Browse files
feat(scenarios): add meter-value-zero negative control scenario (#169)
Adds a `meter-value-zero` scenario: a four minute transaction whose cumulative energy register reads zero from `meterStart` through three `MeterValues` samples to `meterStop`. `expectedFailures` is empty and the scenario asserts `no_failures`. This is the corpus's first negative control. A flat register is not a detectable fault today, because `METER_VALUE_ANOMALY` fires only on readings that go negative or decrease and an all-zero series does neither. The scenario pins that down, so a future change to the rule cannot start reporting an idle meter without a named test failure. Whether a stuck register deserves a rule of its own is tracked separately in #144. Takes the scenario registry to 20, updating the count in the registry test, the external fixture test, and both READMEs. The detection-rule count stays 16. Closes #108
1 parent e28a1eb commit ca2bb0e

7 files changed

Lines changed: 190 additions & 6 deletions

File tree

.changeset/brave-pandas-count.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@ocpp-debugkit/toolkit': patch
3+
---
4+
5+
Add a meter-value-zero scenario to ensure a flat zero meter register does not report a failure.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ validate behavior against known scenarios.
2222
firmware update failures, suspicious session duration, slow CSMS responses,
2323
heartbeat interval violations, meter value anomalies, unresponsive CSMS, and
2424
repeated boot notifications.
25-
- **Scenario Evaluator**19 predefined scenarios with expected failure
25+
- **Scenario Evaluator**20 predefined scenarios with expected failure
2626
outcomes for testing the analysis engine. Supports external scenario files.
2727
- **Replay Engine** — Deterministic, pure replay engine with step forward/back,
2828
jump-to-event, and configurable playback speed.

packages/toolkit/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ report generation, React components, and CLI.
2020
firmware update failures, suspicious session duration, slow CSMS responses,
2121
heartbeat interval violations, meter value anomalies, unresponsive CSMS, and
2222
repeated boot notifications.
23-
- **Scenario Evaluator**19 predefined scenarios with expected failure
23+
- **Scenario Evaluator**20 predefined scenarios with expected failure
2424
outcomes for testing the analysis engine. Supports external scenario files.
2525
- **Replay Engine** — Deterministic, pure replay engine with step forward/back,
2626
jump-to-event, and configurable playback speed. No timers or I/O.
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
export default {
2+
name: 'meter-value-zero',
3+
description:
4+
'Transaction with a flat zero meter register throughout. A zero reading is a valid negative control and must not trigger METER_VALUE_ANOMALY.',
5+
trace: {
6+
traceId: 'scenario-meter-value-zero',
7+
metadata: {
8+
stationId: 'CS-SYNTHETIC-021',
9+
ocppVersion: '1.6',
10+
source: 'synthetic-scenario',
11+
description:
12+
'Station completes a four-minute transaction while the cumulative energy register remains at zero.',
13+
},
14+
events: [
15+
{
16+
timestamp: '2026-01-15T09:00:00.000Z',
17+
direction: 'CS_TO_CSMS',
18+
message: [
19+
2,
20+
'msg-001',
21+
'BootNotification',
22+
{
23+
chargePointVendor: 'SyntheticVendor',
24+
chargePointModel: 'SM-100',
25+
chargePointSerialNumber: 'CS-SYNTHETIC-021',
26+
firmwareVersion: '1.0.0',
27+
},
28+
],
29+
},
30+
{
31+
timestamp: '2026-01-15T09:00:00.500Z',
32+
direction: 'CSMS_TO_CS',
33+
message: [
34+
3,
35+
'msg-001',
36+
{ currentTime: '2026-01-15T09:00:00.500Z', interval: 300, status: 'Accepted' },
37+
],
38+
},
39+
{
40+
timestamp: '2026-01-15T09:00:30.000Z',
41+
direction: 'CS_TO_CSMS',
42+
message: [2, 'msg-002', 'Authorize', { idTag: 'SYNTHETIC-TAG-021' }],
43+
},
44+
{
45+
timestamp: '2026-01-15T09:00:30.500Z',
46+
direction: 'CSMS_TO_CS',
47+
message: [3, 'msg-002', { idTagInfo: { status: 'Accepted' } }],
48+
},
49+
{
50+
timestamp: '2026-01-15T09:01:00.000Z',
51+
direction: 'CS_TO_CSMS',
52+
message: [
53+
2,
54+
'msg-003',
55+
'StartTransaction',
56+
{ connectorId: 1, idTag: 'SYNTHETIC-TAG-021', meterStart: 0 },
57+
],
58+
},
59+
{
60+
timestamp: '2026-01-15T09:01:00.500Z',
61+
direction: 'CSMS_TO_CS',
62+
message: [3, 'msg-003', { idTagInfo: { status: 'Accepted' }, transactionId: 100021 }],
63+
},
64+
{
65+
timestamp: '2026-01-15T09:02:00.000Z',
66+
direction: 'CS_TO_CSMS',
67+
message: [
68+
2,
69+
'msg-004',
70+
'MeterValues',
71+
{
72+
connectorId: 1,
73+
transactionId: 100021,
74+
meterValue: [
75+
{
76+
sampledValue: [
77+
{ value: '0', measurand: 'Energy.Active.Import.Register', unit: 'Wh' },
78+
],
79+
},
80+
],
81+
},
82+
],
83+
},
84+
{
85+
timestamp: '2026-01-15T09:02:00.500Z',
86+
direction: 'CSMS_TO_CS',
87+
message: [3, 'msg-004', {}],
88+
},
89+
{
90+
timestamp: '2026-01-15T09:03:00.000Z',
91+
direction: 'CS_TO_CSMS',
92+
message: [
93+
2,
94+
'msg-005',
95+
'MeterValues',
96+
{
97+
connectorId: 1,
98+
transactionId: 100021,
99+
meterValue: [
100+
{
101+
sampledValue: [
102+
{ value: '0', measurand: 'Energy.Active.Import.Register', unit: 'Wh' },
103+
],
104+
},
105+
],
106+
},
107+
],
108+
},
109+
{
110+
timestamp: '2026-01-15T09:03:00.500Z',
111+
direction: 'CSMS_TO_CS',
112+
message: [3, 'msg-005', {}],
113+
},
114+
{
115+
timestamp: '2026-01-15T09:04:00.000Z',
116+
direction: 'CS_TO_CSMS',
117+
message: [
118+
2,
119+
'msg-006',
120+
'MeterValues',
121+
{
122+
connectorId: 1,
123+
transactionId: 100021,
124+
meterValue: [
125+
{
126+
sampledValue: [
127+
{ value: '0', measurand: 'Energy.Active.Import.Register', unit: 'Wh' },
128+
],
129+
},
130+
],
131+
},
132+
],
133+
},
134+
{
135+
timestamp: '2026-01-15T09:04:00.500Z',
136+
direction: 'CSMS_TO_CS',
137+
message: [3, 'msg-006', {}],
138+
},
139+
{
140+
timestamp: '2026-01-15T09:05:00.000Z',
141+
direction: 'CS_TO_CSMS',
142+
message: [
143+
2,
144+
'msg-007',
145+
'StopTransaction',
146+
{
147+
transactionId: 100021,
148+
idTag: 'SYNTHETIC-TAG-021',
149+
meterStop: 0,
150+
reason: 'Local',
151+
},
152+
],
153+
},
154+
{
155+
timestamp: '2026-01-15T09:05:00.500Z',
156+
direction: 'CSMS_TO_CS',
157+
message: [3, 'msg-007', { idTagInfo: { status: 'Accepted' } }],
158+
},
159+
],
160+
},
161+
expectedFailures: [],
162+
assertions: [{ type: 'no_failures', params: {} }],
163+
};

packages/toolkit/src/scenarios/index.test.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
firmwareUpdateFailureScenario,
2323
refusedAuthorizationScenario,
2424
heartbeatTimeoutScenario,
25+
meterValueZeroScenario,
2526
} from './index.js';
2627
import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index.js';
2728

@@ -30,8 +31,8 @@ import { parseTrace, buildSessionTimeline, detectFailures } from '../core/index.
3031
// ---------------------------------------------------------------------------
3132

3233
describe('scenario registry', () => {
33-
it('exports exactly 19 scenarios', () => {
34-
expect(scenarios).toHaveLength(19);
34+
it('exports exactly 20 scenarios', () => {
35+
expect(scenarios).toHaveLength(20);
3536
});
3637

3738
it('exports scenario names in order', () => {
@@ -55,6 +56,7 @@ describe('scenario registry', () => {
5556
'firmware-update-failure',
5657
'refused-authorization',
5758
'heartbeat-timeout',
59+
'meter-value-zero',
5860
]);
5961
});
6062

@@ -87,6 +89,7 @@ describe('scenario registry', () => {
8789
expect(getScenario('firmware-update-failure')).toBe(firmwareUpdateFailureScenario);
8890
expect(getScenario('refused-authorization')).toBe(refusedAuthorizationScenario);
8991
expect(getScenario('heartbeat-timeout')).toBe(heartbeatTimeoutScenario);
92+
expect(getScenario('meter-value-zero')).toBe(meterValueZeroScenario);
9093
});
9194

9295
it('getScenario returns undefined for unknown name', () => {
@@ -242,6 +245,14 @@ describe('scenario engine integration', () => {
242245
const failures = detectFailures(result.events, sessions);
243246
expect(failures.some((f) => f.code === 'TIMEOUT_NO_HEARTBEAT')).toBe(true);
244247
});
248+
249+
it('meter-value-zero: no failures detected', () => {
250+
const trace = JSON.stringify(meterValueZeroScenario.trace);
251+
const result = parseTrace(trace);
252+
const sessions = buildSessionTimeline(result.events);
253+
const failures = detectFailures(result.events, sessions);
254+
expect(failures).toHaveLength(0);
255+
});
245256
});
246257

247258
// ---------------------------------------------------------------------------

packages/toolkit/src/scenarios/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import firmwareUpdateSuccess from './__scenarios__/firmware-update-success.js';
2424
import firmwareUpdateFailure from './__scenarios__/firmware-update-failure.js';
2525
import refusedAuthorization from './__scenarios__/refused-authorization.js';
2626
import heartbeatTimeout from './__scenarios__/heartbeat-timeout.js';
27+
import meterValueZero from './__scenarios__/meter-value-zero.js';
2728

2829
// ---------------------------------------------------------------------------
2930
// Scenarios derived from core fixtures
@@ -74,6 +75,7 @@ const firmwareUpdateSuccessScenario: Scenario = firmwareUpdateSuccess as unknown
7475
const firmwareUpdateFailureScenario: Scenario = firmwareUpdateFailure as unknown as Scenario;
7576
const refusedAuthorizationScenario: Scenario = refusedAuthorization as unknown as Scenario;
7677
const heartbeatTimeoutScenario: Scenario = heartbeatTimeout as unknown as Scenario;
78+
const meterValueZeroScenario: Scenario = meterValueZero as unknown as Scenario;
7779

7880
// ---------------------------------------------------------------------------
7981
// Registry
@@ -99,6 +101,7 @@ export const scenarios = [
99101
firmwareUpdateFailureScenario,
100102
refusedAuthorizationScenario,
101103
heartbeatTimeoutScenario,
104+
meterValueZeroScenario,
102105
] as const;
103106

104107
export const scenarioNames = [
@@ -121,6 +124,7 @@ export const scenarioNames = [
121124
'firmware-update-failure',
122125
'refused-authorization',
123126
'heartbeat-timeout',
127+
'meter-value-zero',
124128
] as const;
125129

126130
export {
@@ -143,6 +147,7 @@ export {
143147
firmwareUpdateFailureScenario,
144148
refusedAuthorizationScenario,
145149
heartbeatTimeoutScenario,
150+
meterValueZeroScenario,
146151
};
147152

148153
export { compareScenarioReports } from './compare.js';

tests/external-fixture/test.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,8 @@ const scenarios = await import('@ocpp-debugkit/toolkit/scenarios');
158158

159159
assert(Array.isArray(scenarios.scenarios), 'scenarios is an array');
160160
assert(
161-
scenarios.scenarios.length === 19,
162-
`19 scenarios exported (got ${scenarios.scenarios.length})`,
161+
scenarios.scenarios.length === 20,
162+
`20 scenarios exported (got ${scenarios.scenarios.length})`,
163163
);
164164
assert(typeof scenarios.getScenario === 'function', 'getScenario is a function');
165165
assert(scenarios.getScenario('normal-session') !== undefined, 'normal-session scenario exists');

0 commit comments

Comments
 (0)