Skip to content

Navigation Menu

Sign in
Sign up

feat(api): derived hydraulic channels on MachineSnapshot #802

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
ChampionDesigns wants to merge 3 commits into decentespresso:main
base: main
Choose a base branch
Loading
from ChampionDesigns:ben/derived-channels-r2
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions assets/api/rest_v1.yml
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -5825,6 +5825,25 @@ components:
type: integer
steamTemperature:
type: number
puckResistance:
type: number
description: >
Puck hydraulic resistance R = P / F2 in bar·s2/mL2, derived
from pressure and flow. Only computed when flow >= 0.3 mL/s and
pressure >= 0.3 bar; when gated the key is omitted entirely
(never null).
loadImpedance:
type: number
description: >
Hydraulic load impedance Z = P / F in bar·s/mL, derived from
pressure and flow. Same >= 0.3 gating as puckResistance; key
omitted when gated.
hydraulicPower:
type: number
description: >
Hydraulic power delivered to the puck, W = 0.1 · P · F in watts
(1 bar ×ばつ 1 mL/s = 0.1 W; espresso is roughly 0.5–4 W). Same >= 0.3
gating as puckResistance; key omitted when gated.
MachineState:
type: string
enum:
Expand Down
19 changes: 19 additions & 0 deletions assets/api/websocket_v1.yml
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,25 @@ components:
type: integer
steamTemperature:
type: number
puckResistance:
type: number
description: >
Puck hydraulic resistance R = P / F2 in bar·s2/mL2, derived
from pressure and flow. Only computed when flow >= 0.3 mL/s and
pressure >= 0.3 bar; when gated the key is omitted entirely
(never null).
loadImpedance:
type: number
description: >
Hydraulic load impedance Z = P / F in bar·s/mL, derived from
pressure and flow. Same >= 0.3 gating as puckResistance; key
omitted when gated.
hydraulicPower:
type: number
description: >
Hydraulic power delivered to the puck, W = 0.1 · P · F in watts
(1 bar ×ばつ 1 mL/s = 0.1 W; espresso is roughly 0.5–4 W). Same >= 0.3
gating as puckResistance; key omitted when gated.
MachineState:
type: string
enum:
Expand Down
12 changes: 12 additions & 0 deletions doc/Api.md
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ For browser clients on a different origin, `ETag` is exposed via `Access-Control
| GET | `/api/v1/machine/scaleCalibration` | Read decoded scale-calibration state (step, cell, sub-state, seconds remaining, status) — Bengle only, 404 elsewhere | |
| PUT | `/api/v1/machine/scaleCalibration` | Start `zero`/`latch`/`abort` calibration step (`weightGrams` 1–10000 required for `latch`); 202 accepted / 409 rejected (busy or shot in progress) — Bengle only | |

#### Derived hydraulic channels

`MachineSnapshot` carries three channels the app computes from pressure and group flow rather than
reads from the machine: `puckResistance` (R = P / F2, bar·s2/mL2), `loadImpedance` (Z = P / F,
bar·s/mL) and `hydraulicPower` (W = 0.1 · P · F, watts — 1 bar ×ばつ 1 mL/s = 0.1 W, and espresso sits
at roughly 0.5–4 W).

All three are gated on flow ≥ 0.3 mL/s **and** pressure ≥ 0.3 bar. Below either threshold the key is
omitted from the payload entirely rather than sent as `null`. A client must read an absent key as
"not computable right now", never as zero. The channels appear wherever a `MachineSnapshot` does,
`/ws/v1/machine/snapshot` included.

#### Firmware updates

The catalog endpoint is available offline and without a connected machine. It returns bundled artifact metadata, compatibility and version eligibility, the recommended artifact, tri-state `updateAvailable`, and the shared machine operation state. The bundled Phase 1 artifact is official DE1 firmware build 1352 for `DE1Pro`, `DE1XL`, `DE1XXL`, and `DE1XXXL`.
Expand Down
41 changes: 41 additions & 0 deletions lib/src/models/device/machine.dart
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,41 @@ class MachineSnapshot {
required this.steamTemperature,
});

// Derived hydraulic channels R / Z / W, computed on read from the raw
// [pressure] and [flow] fields — never stored — so already-recorded history
// shots gain these channels with zero migration ([fromJson] does not read
// them; [toJson] recomputes them).

/// Shared gate for the derived channels: returns [value] only when
/// `flow >= 0.3 mL/s && pressure >= 0.3 bar` (below that the ratios are
/// numerically meaningless noise) and inputs and result are finite. The
/// finite guard is mandatory: `jsonEncode` throws on NaN/Infinity and
/// [toJson] is streamed on the live `/ws/v1/machine/snapshot` websocket.
double? _derivedOrNull(double value) {
if (flow < 0.3 || pressure < 0.3) return null;
if (!flow.isFinite || !pressure.isFinite || !value.isFinite) return null;
return value;
}

/// Puck hydraulic resistance **R = P / F2** in bar·s2/mL2.
///
/// How strongly the puck resists water flow; rises as the puck compacts
/// or clogs, drops on channeling/erosion. `null` (and omitted from
/// [toJson]) unless flow ≥ 0.3 mL/s and pressure ≥ 0.3 bar.
double? get puckResistance => _derivedOrNull(pressure / (flow * flow));

/// Hydraulic load impedance **Z = P / F** in bar·s/mL.
///
/// Pressure-to-flow ratio at the current operating point (the "AC"
/// analogue of [puckResistance]). Same ≥ 0.3 gating as [puckResistance].
double? get loadImpedance => _derivedOrNull(pressure / flow);

/// Hydraulic power delivered to the puck **W = 0.1 · P · F** in watts
/// (1 bar ×ばつ 1 mL/s = 0.1 W; espresso is roughly 0.5–4 W).
///
/// Same ≥ 0.3 gating as [puckResistance].
double? get hydraulicPower => _derivedOrNull(0.1 * pressure * flow);

MachineSnapshot copyWith({
DateTime? timestamp,
MachineStateSnapshot? state,
Expand Down Expand Up @@ -108,6 +143,12 @@ class MachineSnapshot {
'targetGroupTemperature': targetGroupTemperature,
'profileFrame': profileFrame,
'steamTemperature': steamTemperature,
// Derived channels. Keys are OMITTED (not null) when gated — old skins
// never see them, and consumers can rely on key presence as the
// validity signal.
if (puckResistance != null) 'puckResistance': puckResistance,
if (loadImpedance != null) 'loadImpedance': loadImpedance,
if (hydraulicPower != null) 'hydraulicPower': hydraulicPower,
};
}

Expand Down
106 changes: 106 additions & 0 deletions test/models/machine_snapshot_derived_test.dart
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Tests for the derived hydraulic channels R (puckResistance),
// Z (loadImpedance) and W (hydraulicPower) on MachineSnapshot.
import 'dart:convert';

import 'package:flutter_test/flutter_test.dart';
import 'package:reaprime/src/models/device/machine.dart';

MachineSnapshot _snapshot({required double flow, required double pressure}) =>
MachineSnapshot(
timestamp: DateTime.utc(2026, 7, 17, 12, 0, 0),
state: const MachineStateSnapshot(
state: MachineState.espresso,
substate: MachineSubstate.pouring,
),
flow: flow,
pressure: pressure,
targetFlow: 2.0,
targetPressure: 9.0,
mixTemperature: 92.0,
groupTemperature: 93.0,
targetMixTemperature: 93.0,
targetGroupTemperature: 93.0,
profileFrame: 1,
steamTemperature: 140,
);

void main() {
group('MachineSnapshot derived channels', () {
test('computes R, Z and W from pressure and flow', () {
final snapshot = _snapshot(pressure: 9.0, flow: 2.0);
expect(snapshot.puckResistance, closeTo(2.25, 1e-9));
expect(snapshot.loadImpedance, closeTo(4.5, 1e-9));
expect(snapshot.hydraulicPower, closeTo(1.8, 1e-9));

final json = snapshot.toJson();
expect(json['puckResistance'], closeTo(2.25, 1e-9));
expect(json['loadImpedance'], closeTo(4.5, 1e-9));
expect(json['hydraulicPower'], closeTo(1.8, 1e-9));
});

test('gates on low flow: getters null, keys absent from toJson', () {
final snapshot = _snapshot(pressure: 9.0, flow: 0.2);
expect(snapshot.puckResistance, isNull);
expect(snapshot.loadImpedance, isNull);
expect(snapshot.hydraulicPower, isNull);

final json = snapshot.toJson();
expect(json.containsKey('puckResistance'), isFalse);
expect(json.containsKey('loadImpedance'), isFalse);
expect(json.containsKey('hydraulicPower'), isFalse);
});

test('gates on low pressure: getters null, keys absent from toJson', () {
final snapshot = _snapshot(pressure: 0.2, flow: 2.0);
expect(snapshot.puckResistance, isNull);
expect(snapshot.loadImpedance, isNull);
expect(snapshot.hydraulicPower, isNull);

final json = snapshot.toJson();
expect(json.containsKey('puckResistance'), isFalse);
expect(json.containsKey('loadImpedance'), isFalse);
expect(json.containsKey('hydraulicPower'), isFalse);
});

test('zero flow: keys absent and jsonEncode does not throw', () {
final snapshot = _snapshot(pressure: 9.0, flow: 0.0);
final json = snapshot.toJson();
expect(json.containsKey('puckResistance'), isFalse);
expect(json.containsKey('loadImpedance'), isFalse);
expect(json.containsKey('hydraulicPower'), isFalse);
// jsonEncode throws on NaN/Infinity — the gate must keep the
// division-by-zero results out of the websocket payload entirely.
expect(() => jsonEncode(snapshot.toJson()), returnsNormally);
});

test('round-trip recomputes derived keys from raw fields', () {
final original = _snapshot(pressure: 9.0, flow: 2.0);
// Encode/decode simulates a shot stored to history and read back.
final stored =
jsonDecode(jsonEncode(original.toJson())) as Map<String, dynamic>;
final restored = MachineSnapshot.fromJson(stored);
// fromJson never reads the derived keys; toJson recomputes them from
// the raw pressure/flow fields, so stored history gains the channels
// on read with zero migration.
final json = restored.toJson();
expect(json['puckResistance'], closeTo(2.25, 1e-9));
expect(json['loadImpedance'], closeTo(4.5, 1e-9));
expect(json['hydraulicPower'], closeTo(1.8, 1e-9));
});

test(
'boundary: flow and pressure exactly 0.3 are emitted (gate is >=)',
() {
final snapshot = _snapshot(pressure: 0.3, flow: 0.3);
expect(snapshot.puckResistance, isNotNull);
expect(snapshot.loadImpedance, isNotNull);
expect(snapshot.hydraulicPower, isNotNull);

final json = snapshot.toJson();
expect(json['puckResistance'], closeTo(0.3 / (0.3 * 0.3), 1e-9));
expect(json['loadImpedance'], closeTo(1.0, 1e-9));
expect(json['hydraulicPower'], closeTo(0.009, 1e-12));
},
);
});
}
Loading

AltStyle によって変換されたページ (->オリジナル) /