test(focas): bring the vendored focas-mock onto the real 31i-B wire shapes; integration tests follow

The mock had served the shapes the driver once agreed with it on, so it
validated the very defects just fixed: it ANSWERED the non-existent 0x00D3
cnc_getfigure, wrote base/exponent 0/0 into position blocks, put the macro
decimal at offset 4, and served spindle 'max rpm' as a raw constant.

Ported from the sister ignitionoee mock (local divergence recorded in
VENDORED.md; the LE timer and alarm cap are kept):
- 0x00D3 refused with EW_FUNC, as the real control does.
- 0x0026 blocks carry base 10 / position_figures exponent; signed arg2,
  arg2 = 0 or past the axis count refused EW_ATTRIB.
- cnc_rdmacro is the 8-byte ODBM shape, 9-digit mantissa, unset = vacant.
- servo/spindle meter records carry the envelope; spindle meters are
  1/16384-of-full-scale counts (motor speed = speed / max_rpm).

Integration tests: spindle test reads Load/MotorSpeedPct percentages; the
position-scale test now proves the block's own scale wins; new
Vacant_positions_and_macros_read_bad_over_the_wire. 12/12 pass against the
mock run locally (python -m focas_mock.cli serve, OTOPCUA_FOCAS_SIM_ENDPOINT).
This commit is contained in:
Joseph Doherty
2026-10-07 14:12:06 -04:00
parent 26b512adba
commit 9b51dbca60
5 changed files with 279 additions and 55 deletions
@@ -43,3 +43,18 @@ Copy-Item "$src\Dockerfile" "$dest\"
Update the snapshot date at the top of this file afterward. No other
files belong here — the Docker build context is just the Python package
and its metadata.
## Local divergence (do not lose on refresh)
`src/focas_mock/server.py` and `defaults.py` carry OtOpcUa-local edits that make the wire
shapes match a real FANUC 31i-B (Makino Z-34184) instead of shapes the driver and the mock had
agreed on between themselves. A refresh that overwrites `src/` must re-apply them:
- the little-endian `cnc_rdtimer` minute/msec encoding (2026-06-25, live-validated);
- the alarm count cap on `cnc_rdalmmsg2`;
- (2026-10-07, ported from the sister ignitionoee FOCAS mock) `0x00D3` ("cnc_getfigure", not a
real FOCAS wire command) refused with `EW_FUNC`; `0x0026` position blocks carrying their own
`base/exponent` (`position_figures` is now that exponent) and a signed `arg2`;
`cnc_rdmacro` as the 8-byte ODBM shape (`dec_val` at `[6..7]`, 9-digit mantissa, unset
macro = vacant); servo/spindle meter records with the `base/exponent` envelope, spindle
meters in 1/16384-of-full-scale counts.
@@ -78,9 +78,12 @@ def make_default_state(profile: Mapping[str, Any]) -> dict[str, Any]:
"acts": 3200,
"acts2": [3200] + [0] * (max_spindles - 1),
"axis_names": axis_names[:max_axis],
# Per-axis position decimal places served by cnc_getfigure (command 0x00d3).
# Default every configured axis to 0 ⇒ 10^0 = 1.0 scale, leaving existing
# position reads unchanged. mock_patch can override individual axes.
# Per-axis position scale, served INSIDE each cnc_rddynamic2 (0x0026) block as
# base 10 / this exponent — NOT by a separate command (0x00d3 "cnc_getfigure" was
# never real; the mock refuses it with EW_FUNC like the control does). Default 0 ⇒
# 10^0 = 1.0, leaving existing position reads unchanged. mock_patch can override an
# axis with an int exponent, or a dict {"base": .., "exponent": ..} — e.g.
# {"exponent": 65535} for the vacant marker.
"position_figures": {name: 0 for name in axis_names[:max_axis]},
"spindle_names": spindle_names,
"parameters": {
@@ -7,6 +7,7 @@ from typing import Any, Awaitable, Callable
from .constants import (
ADMIN_METHODS,
EW_ATTRIB,
EW_FUNC,
EW_HANDLE,
EW_NUMBER,
@@ -24,7 +25,52 @@ from .profiles import load_profile
Handler = Callable[[dict[str, Any]], Awaitable[tuple[int, dict[str, Any] | None, str | None]]]
class WireRefusal(Exception):
"""One response block refuses with this FOCAS ``rc``.
Raised from a payload builder when the COMMAND exists but its ARGUMENTS name nothing the
control would answer (``cnc_rddynamic2`` with ``arg2 = 0``, for instance). Command-level
refusals live in ``FocasMockServer._WIRE_REFUSED_COMMANDS``. Ported from the sister
ignitionoee mock (Focas.Wire-020).
"""
def __init__(self, rc: int) -> None:
super().__init__(f"refused with rc={rc}")
self.rc = rc
class FocasMockServer:
# OtOpcUa-local changes to this vendored snapshot (2026-10-07), ported from the sister
# ignitionoee FOCAS mock so the wire shapes match a real FANUC 31i-B (Makino Z-34184) rather
# than shapes the driver and the mock once agreed on between themselves:
# - 0x00D3 ("cnc_getfigure") is refused with EW_FUNC, as the real control does;
# - 0x0026 position blocks carry their own base/exponent;
# - cnc_rdmacro is the 8-byte ODBM shape with dec_val at [6..7];
# - servo/spindle meter records carry the base/exponent envelope, spindle meters in
# 1/16384-of-full-scale counts.
# The little-endian cnc_rdtimer encoding and the alarm count cap stay as they were.
# cnc_rdmacro (ODBM) encoding constants — see _wire_rdmacro / _normalize_macro.
_MACRO_ODBM_LENGTH = 10
_MACRO_VACANT_DEC = -1
_MACRO_SIGNIFICANT_DIGITS = 9
# Commands this mock REFUSES, and with which rc — the real control's answer. 0x00D3 does not
# exist in the FOCAS wire protocol; serving it let a driver validate a command the real
# 31i-B refuses with rc=1.
_WIRE_REFUSED_COMMANDS = {0xD3: EW_FUNC}
# Default base served inside a 0x0026 position block (every block of the live 31i-B
# capture reports base 10).
_POSITION_DEFAULT_BASE = 10
# Servo/spindle meter records carry the same base/exponent envelope (live: base 10, exp 0).
_METER_BASE = 10
# SPINDLE meters (0x0040) are a fraction of full scale in 1/16384 units: 16384 = 100% of
# rated load (metric 0) or of maximum spindle speed (metric 1). SERVO meters are direct %.
_METER_FULL_SCALE = 16384
def __init__(
self,
host: str,
@@ -208,10 +254,22 @@ class FocasMockServer:
def _wire_response_block(self, request_block: bytes) -> bytes:
command = int.from_bytes(request_block[6:8], "big") if len(request_block) >= 8 else 0
payload = self._wire_payload(command, request_block)
refused_rc = self._WIRE_REFUSED_COMMANDS.get(command)
if refused_rc is not None:
return self._make_wire_response_block(command, b"", request_block, rc=refused_rc)
try:
payload = self._wire_payload(command, request_block)
except WireRefusal as refusal:
return self._make_wire_response_block(command, b"", request_block, rc=refusal.rc)
return self._make_wire_response_block(command, payload, request_block)
def _make_wire_response_block(self, command: int, payload: bytes, request_block: bytes | None = None) -> bytes:
def _make_wire_response_block(
self,
command: int,
payload: bytes,
request_block: bytes | None = None,
rc: int = EW_OK,
) -> bytes:
block_len = 0x10 + len(payload)
block = bytearray(block_len)
block[0:2] = block_len.to_bytes(2, "big")
@@ -219,6 +277,7 @@ class FocasMockServer:
block[2:8] = request_block[2:8]
else:
block[6:8] = command.to_bytes(2, "big")
block[8:10] = int(rc).to_bytes(2, "big", signed=True)
block[14:16] = len(payload).to_bytes(2, "big")
block[16:] = payload
return bytes(block)
@@ -256,8 +315,6 @@ class FocasMockServer:
return self._wire_spindle_metric(request_block)
if command == 0x56:
return self._wire_servo_meter()
if command == 0xD3:
return self._wire_position_figures()
if command == 0x57:
mode = self.store.snapshot()["operation_mode"]
return self._u16(int(mode.get("mode", 0) if isinstance(mode, dict) else mode))
@@ -322,23 +379,54 @@ class FocasMockServer:
return self._u32(value)
def _wire_dynamic_axis(self, request_block: bytes) -> bytes:
"""Serves `cnc_rddynamic2` (0x0026) positions, honouring a SIGNED `arg2` the way the real
31i-B does (probed 2026-08-12 by the sister ignitionoee project, Focas.Wire-017/020):
`arg2 = 1..n` -> exactly one 8-byte block for that axis; `arg2 = -1` -> every slot in one
block; `arg2 = 0` or past the axis count -> refused (EW_ATTRIB)."""
selector = self._block_u32(request_block, 8)
axis = self._block_u32(request_block, 12)
axis = self._block_i32(request_block, 12)
field = {4: "absolute", 1: "machine", 6: "relative", 7: "distance"}.get(selector, "absolute")
state = self.store.snapshot()
names = list(state["axis_names"])
dynamic_axes = state["dynamic"]["axes"]
if axis == 0 or axis > len(names):
raise WireRefusal(EW_ATTRIB)
if axis > 0:
name = names[axis - 1]
return self._wire_position_block(state, name, dynamic_axes.get(name, {}).get(field, 0))
payload = bytearray()
for name in names:
position = dynamic_axes.get(name, {})
payload += self._u32(int(position.get(field, 0)))
payload += self._u32(0)
if axis > 0 and axis <= len(names):
name = names[axis - 1]
position = dynamic_axes.get(name, {})
return self._u32(int(position.get(field, 0))) + self._u32(0)
payload += self._wire_position_block(state, name, dynamic_axes.get(name, {}).get(field, 0))
return bytes(payload)
def _wire_position_block(self, state: dict[str, Any], name: str, raw: int) -> bytes:
"""Encodes one 8-byte `cnc_rddynamic2` position block: value, then its OWN scale.
[0..3] i32 raw value
[4..5] u16 base (2 or 10)
[6..7] u16 exponent (0xFFFF = the slot holds no valid position)
`position_figures[name]` is the exponent (base 10); a dict value
`{"base": .., "exponent": ..}` overrides the base too, so a profile can serve a
deliberately undecodable block and prove the client Bads that one axis node.
"""
base, exponent = self._position_scale(state, name)
return (
self._u32(int(raw))
+ (base & 0xFFFF).to_bytes(2, "big")
+ (exponent & 0xFFFF).to_bytes(2, "big")
)
def _position_scale(self, state: dict[str, Any], name: str) -> tuple[int, int]:
"""Returns the (base, exponent) pair `position_figures` configures for one axis."""
configured = state.get("position_figures", {}).get(name, 0)
if isinstance(configured, dict):
return (
int(configured.get("base", self._POSITION_DEFAULT_BASE)),
int(configured.get("exponent", 0)),
)
return self._POSITION_DEFAULT_BASE, int(configured)
def _wire_wrparam(self, request_block: bytes) -> None:
datano = self._block_u32(request_block, 8)
extra = request_block[28:]
@@ -346,11 +434,38 @@ class FocasMockServer:
self.store.merge_patch({"parameters": {str(datano): {"type": "long", "value": value, "decimal": 0}}})
def _wire_rdmacro(self, request_block: bytes) -> bytes:
"""Encodes an ODBM `cnc_rdmacro` (0x0015) response payload, as a real 31i-B sends it
(golden capture `ref-macro-500-0x0015.bin`: `05 f5 e1 00 00 0a 00 08` = 1.0):
[0..3] i32 mantissa (normalised to 9 significant digits)
[4..5] i16 length field — a CONSTANT 10
[6..7] i16 dec_val — decimal places; 0xFFFF (-1) = VACANT
A macro the profile does not mock is emitted as vacant, as a real control answers an
undefined variable.
"""
number = self._block_u32(request_block, 8)
macro = self.store.snapshot()["macros"].get(str(number), {})
value = int(macro.get("value", 0)) if isinstance(macro, dict) else 0
decimal = int(macro.get("decimal", 0)) if isinstance(macro, dict) else 0
return self._u32(value) + self._u16(decimal) + self._u16(0)
macro = self.store.snapshot()["macros"].get(str(number))
if isinstance(macro, dict):
mantissa, dec_val = self._normalize_macro(
int(macro.get("value", 0)), int(macro.get("decimal", 0))
)
else:
mantissa, dec_val = 0, self._MACRO_VACANT_DEC
return self._u32(mantissa) + self._u16(self._MACRO_ODBM_LENGTH) + self._u16(dec_val)
def _normalize_macro(self, value: int, decimal: int) -> tuple[int, int]:
"""Turns a configured fixed-point `(value, decimal)` into the `(mantissa, dec_val)` a CNC
returns: the mantissa padded to 9 significant digits, `dec_val` raised to match
(live: `#502 = 270` -> `270000000/10^6`). The real value is unchanged."""
if decimal < 0:
return 0, self._MACRO_VACANT_DEC
if value == 0:
return 0, decimal
pad = self._MACRO_SIGNIFICANT_DIGITS - len(str(abs(value)))
if pad <= 0:
return value, decimal
return value * (10**pad), decimal + pad
def _wire_wrmacro(self, request_block: bytes) -> None:
number = self._block_u32(request_block, 8)
@@ -377,31 +492,34 @@ class FocasMockServer:
payload = bytearray()
for name in state["axis_names"]:
entry = configured.get(name, {"name": name, "value": 0})
# 8-byte record (live 31i-B): value, then the base/exponent envelope. No axis name —
# the client pairs names positionally from the companion 0x0089 block.
payload += self._u32(int(entry.get("value", 0)))
payload += self._u16(0)
payload += self._u16(1)
payload += self._name_record(name, 4)
return bytes(payload)
def _wire_position_figures(self) -> bytes:
state = self.store.snapshot()
figures = state.get("position_figures", {})
payload = bytearray()
for name in state["axis_names"]:
payload += self._u16(int(figures.get(name, 0)))
payload += self._u16(self._METER_BASE)
payload += self._u16(int(entry.get("exponent", 0)))
return bytes(payload)
def _wire_spindle_metric(self, request_block: bytes) -> bytes:
# Both metrics are 1/16384-of-full-scale COUNTS on the wire, not the human units the
# config holds. Metric 0: load % of rated (16384 = 100%). Metric 1: motor speed as a
# fraction of the spindle's maximum (`speed / max_rpm`) — NOT the maximum itself.
metric = self._block_u32(request_block, 8)
spindle = self.store.snapshot()["spindle"]
if metric == 1:
values = [int(value) for value in spindle.get("max_rpm", [])]
else:
values = [int(entry.get("load", 0)) for entry in spindle.get("load", [])]
maxima = [int(value) for value in spindle.get("max_rpm", [])]
entries = spindle.get("load", [])
values = []
for index, entry in enumerate(entries):
if metric == 1:
maximum = maxima[index] if index < len(maxima) else 0
speed = int(entry.get("speed", 0))
values.append(round(speed * self._METER_FULL_SCALE / maximum) if maximum else 0)
else:
values.append(round(int(entry.get("load", 0)) * self._METER_FULL_SCALE / 100))
payload = bytearray()
for value in values[:4]:
payload += self._u32(value)
payload += self._u32(0)
payload += self._u16(self._METER_BASE)
payload += self._u16(0)
return bytes(payload)
def _wire_program_name(self) -> bytes:
@@ -480,6 +598,12 @@ class FocasMockServer:
return 0
return int.from_bytes(block[offset : offset + 4], "big")
def _block_i32(self, block: bytes, offset: int) -> int:
"""A SIGNED request argument — `-1` is a real selector (all axes / all spindles)."""
if offset + 4 > len(block):
return 0
return int.from_bytes(block[offset : offset + 4], "big", signed=True)
def _u16(self, value: int) -> bytes:
return int(value).to_bytes(2, "big", signed=True)
@@ -236,14 +236,15 @@ public sealed class WireBackendCoverageTests
}
/// <summary>
/// Verifies that per-axis position figures fetched via <c>cnc_getfigure</c> (wire command
/// 0x00D3) scale the published <c>AbsolutePosition</c>, and that the auto figure wins over
/// the configured <c>PositionDecimalPlaces</c> fallback knob.
/// Seed: X axis absolute = 12345, figure = 3 → published value = 12.345 (÷ 10^3).
/// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the wire path.
/// Verifies that the scale carried INSIDE each <c>cnc_rddynamic2</c> position block
/// (base 10 / exponent) scales the published <c>AbsolutePosition</c>, and that it wins over
/// the configured <c>PositionDecimalPlaces</c> fallback knob. (The mock refuses the old
/// <c>cnc_getfigure</c> 0x00D3 with EW_FUNC, as a real 31i-B does.)
/// Seed: X axis absolute = 12345, exponent = 3 → published value = 12.345 (÷ 10^3).
/// Config knob = 1 → fallback would give 1234.5. 12.345 uniquely proves the block scale.
/// </summary>
[Fact]
public async Task Position_figures_scale_axis_via_wire_backend()
public async Task Position_block_scale_scales_axis_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
@@ -261,13 +262,13 @@ public sealed class WireBackendCoverageTests
X = new { absolute = 12345, machine = 12345, relative = 0, distance = 0 },
},
},
// Per-axis decimal-place figures for cnc_getfigure (command 0x00D3).
// The mock's _wire_position_figures() reads state["position_figures"][axisName].
// Per-axis exponent served inside each 0x0026 position block (base 10).
// The mock's _position_scale() reads state["position_figures"][axisName].
position_figures = new { X = 3 },
}, ct);
// PositionDecimalPlaces = 1 is intentionally different from the auto figure (3)
// so the assertion 12.345 uniquely proves the cnc_getfigure path won.
// PositionDecimalPlaces = 1 is intentionally different from the block exponent (3)
// so the assertion 12.345 uniquely proves the block's own scale won.
var driver = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost, PositionDecimalPlaces: 1)],
@@ -295,13 +296,90 @@ public sealed class WireBackendCoverageTests
state.ShouldNotBeNull();
var published = state.LastFixedSnapshots[$"{DeviceHost}/Axes/X/AbsolutePosition"];
// 12345 ÷ 10^3 = 12.345 → auto figure (3) won.
// 12345 ÷ 10^3 = 12.345 → the block's own scale (3) won.
// 12345 ÷ 10^1 = 1234.5 → would mean config knob (1) was used instead.
// 12345 ÷ 10^0 = 12345.0 → would mean no scaling at all.
published.ShouldBe(12.345, tolerance: 0.0001);
}
}
/// <summary>
/// "The control described no value" reads Bad over the real wire, never a Good number:
/// a vacant <c>0x0026</c> position block (exponent <c>0xFFFF</c>) Bads only its own axis
/// node, an unset macro (vacant <c>dec_val</c>) Bads its tag, and a fractional macro on an
/// integer tag is refused rather than truncated — while a normalised 9-digit macro
/// mantissa de-scales to its real value.
/// </summary>
[Fact]
public async Task Vacant_positions_and_macros_read_bad_over_the_wire()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
await _fx.LoadProfileAsync("FWLIB64", ct);
await _fx.PatchStateAsync(new
{
axis_names = new[] { "X", "Y" },
dynamic = new
{
alarm = 0, prgnum = 1, prgmnum = 1, seqnum = 1, actf = 0, acts = 0,
axes = new
{
X = new { absolute = 1, machine = 1, relative = 0, distance = 0 },
Y = new { absolute = 370177, machine = 370177, relative = 0, distance = 0 },
},
},
position_figures = new Dictionary<string, object>
{
["X"] = new { exponent = 0xFFFF }, // vacant-slot marker
["Y"] = 3,
},
macros = new Dictionary<string, object>
{
["502"] = new { value = 270, @decimal = 0 }, // served as 270000000 / 10^6
["508"] = new { value = 1485, @decimal = 1 }, // 148.5
},
}, ct);
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(DeviceHost)],
RawTags = FocasRawTags.Entries(
new FocasTagDefinition("Count502", DeviceHost, "MACRO:502", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("Frac508", DeviceHost, "MACRO:508", FocasDataType.Int32, Writable: false),
new FocasTagDefinition("Unset611", DeviceHost, "MACRO:611", FocasDataType.Float64, Writable: false)),
Probe = new FocasProbeOptions { Enabled = false },
FixedTree = new FocasFixedTreeOptions
{
Enabled = true,
PollInterval = TimeSpan.FromMilliseconds(100),
ProgramPollInterval = TimeSpan.Zero,
TimerPollInterval = TimeSpan.Zero,
},
}, driverInstanceId: "wire-vacant", clientFactory: new WireFocasClientFactory());
await using (drv)
{
await drv.InitializeAsync("{}", ct);
await WaitFor(() =>
drv.GetDeviceState(DeviceHost)?.LastFixedSnapshots
.ContainsKey($"{DeviceHost}/Axes/Y/AbsolutePosition") == true,
TimeSpan.FromSeconds(5));
var snaps = await drv.ReadAsync(
[$"{DeviceHost}/Axes/X/AbsolutePosition", $"{DeviceHost}/Axes/Y/AbsolutePosition",
"Count502", "Frac508", "Unset611"], ct);
snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
Convert.ToDouble(snaps[1].Value).ShouldBe(370.177, tolerance: 1e-9);
snaps[2].StatusCode.ShouldBe(FocasStatusMapper.Good);
snaps[2].Value.ShouldBe(270);
snaps[3].StatusCode.ShouldBe(FocasStatusMapper.BadTypeMismatch);
snaps[4].StatusCode.ShouldBe(FocasStatusMapper.BadNoData);
}
}
private static async Task WaitFor(Func<bool> pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
@@ -210,9 +210,13 @@ public sealed class WireBackendTests
}
}
/// <summary>Verifies that spindle load and max RPM data populate via the wire backend.</summary>
/// <summary>
/// Verifies that the spindle load and motor-speed meters populate via the wire backend as
/// percentages of full scale (the mock serves 1/16384-of-full-scale counts, as a real
/// 31i-B does): S1 load 56% / speed 3200 of 6000 rpm = 53.3%, S2 load 12% / 1800 of 4500 = 40%.
/// </summary>
[Fact]
public async Task Spindle_load_and_max_rpm_populate_via_wire_backend()
public async Task Spindle_load_and_motor_speed_populate_via_wire_backend()
{
if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
var ct = TestContext.Current.CancellationToken;
@@ -260,21 +264,21 @@ public sealed class WireBackendTests
await WaitFor(() =>
{
var state = driver.GetDeviceState(DeviceHost);
return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMaxRpm: true }
return state?.FixedTreeCache is { Capabilities.SpindleLoad: true, Capabilities.SpindleMotorSpeed: true }
&& state.LastSpindleLoads.Count >= 2;
}, TimeSpan.FromSeconds(5));
var snapshots = await driver.ReadAsync(
[$"{DeviceHost}/Spindle/S1/Load",
$"{DeviceHost}/Spindle/S1/MaxRpm",
$"{DeviceHost}/Spindle/S1/MotorSpeedPct",
$"{DeviceHost}/Spindle/S2/Load",
$"{DeviceHost}/Spindle/S2/MaxRpm"], ct);
$"{DeviceHost}/Spindle/S2/MotorSpeedPct"], ct);
snapshots.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
Convert.ToInt32(snapshots[0].Value).ShouldBe(56);
Convert.ToInt32(snapshots[1].Value).ShouldBe(6000);
Convert.ToInt32(snapshots[2].Value).ShouldBe(12);
Convert.ToInt32(snapshots[3].Value).ShouldBe(4500);
Convert.ToDouble(snapshots[0].Value).ShouldBe(56.0, tolerance: 0.01);
Convert.ToDouble(snapshots[1].Value).ShouldBe(3200 * 100.0 / 6000, tolerance: 0.01);
Convert.ToDouble(snapshots[2].Value).ShouldBe(12.0, tolerance: 0.01);
Convert.ToDouble(snapshots[3].Value).ShouldBe(40.0, tolerance: 0.01);
}
}