ember-heat (0.3.19): heat mode in the engine, the loop, the rest and the release (mission item 7)

Ember holds a room temperature or a schedule and the hash follows the duty cycle: the miners run for a share of
every 10-minute period and stop for the rest (src/heat.rs, the PI loop decided once per period; engine.rs tick_heat,
heat_rest and heat_release the way a remote job holds the cards). The temperature source is a typed reading (fresh
two hours) or the coolest card's sensor after 3 minutes of rest with the cooling tail taken off by its slope; no
hardware the app does not have. Settings carry the region, the switch, the set point, the schedule with the window's
clock offset, the typed reading and the learned idle offset; state.heat carries the phase, the duty, the watts and
the one line; POST /api/heat and /api/region. One HEAT line in the log every 30 s for the gate reader. 8 tests with
a model room: a typed reading and the card sensor alone each hold within a degree for four hours.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 85c619f578)
This commit is contained in:
igneum-labs 2026-10-07 09:31:54 +00:00
parent bb3649a266
commit 38486b01f8
6 changed files with 842 additions and 3 deletions

View file

@ -114,6 +114,27 @@ pub struct Settings {
pub power_price_pence: f64,
#[serde(default)]
pub tune_climb: bool,
/// Ember Heat (mission item 7, 7 October 2026; src/heat.rs): the region code the miner chose at first run or in
/// Settings ("" = not chosen; the price above is in that region's minor unit per kWh, typed, never fetched), the
/// heat-mode switch, the set point in degrees, the schedule (slots by minute of the day in the window's clock,
/// `heat_tz_min` minutes east of UTC), the typed room reading and when it was typed (0 = none), and the learned
/// idle offset of the card sensor above the room (0 = the default).
#[serde(default)]
pub region: String,
#[serde(default)]
pub heat_on: bool,
#[serde(default = "nineteen")]
pub heat_set_c: f64,
#[serde(default)]
pub heat_schedule: Vec<crate::heat::Slot>,
#[serde(default)]
pub heat_tz_min: i32,
#[serde(default)]
pub heat_room_c: f64,
#[serde(default)]
pub heat_room_at: f64,
#[serde(default)]
pub heat_offset_c: f64,
/// When this install first ran (unix s), for the "first hour after install" sweep.
#[serde(default)]
pub installed_at: u64,
@ -148,13 +169,16 @@ fn one() -> u32 {
fn balanced() -> String {
"balanced".into()
}
fn nineteen() -> f64 {
19.0
}
fn yes() -> bool {
true
}
impl Default for Settings {
fn default() -> Settings {
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false }
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, region: String::new(), heat_on: false, heat_set_c: 19.0, heat_schedule: Vec::new(), heat_tz_min: 0, heat_room_c: 0.0, heat_room_at: 0.0, heat_offset_c: 0.0, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false, ui_builtin: false }
}
}

View file

@ -93,6 +93,10 @@ pub enum Cmd {
TuneGoal(Option<String>, Option<f64>, Option<bool>),
/// one card's goal override (key, goal; "" = back to the global goal)
TuneCardGoal(String, String),
/// Ember Heat (src/heat.rs): the region code and the typed price (Settings > Electricity, the first-run step)
Region(Option<String>, Option<f64>),
/// Ember Heat: the switch, the set point, the schedule text with the window's clock offset, a typed room reading
Heat(HeatPatch),
/// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the
/// prover found a host that was not there when the node started
RestartNode(String),
@ -106,6 +110,22 @@ pub enum Cmd {
UiBuiltin(bool),
}
/// What POST /api/heat may change; every field optional, the engine keeps the rest.
#[derive(Clone, Debug, Default)]
pub struct HeatPatch {
pub on: Option<bool>,
pub set_c: Option<f64>,
/// the schedule as typed ("" clears it) and the window's minutes east of UTC
pub schedule: Option<(String, i32)>,
/// a room reading the miner typed now, degrees
pub room_c: Option<f64>,
}
/// The heat state before the loop has run (and whenever the mode is off).
fn heat_state_off(on: bool, set_c: f64) -> crate::state::HeatState {
crate::state::HeatState { on, phase: if on { "waiting".into() } else { "off".into() }, set_c, room_source: "none".into(), note: crate::heat::words(on, "waiting", set_c, &crate::heat::Reading::none(), 0.0, 0.0), period_s: crate::heat::PERIOD_S, offset_c: crate::heat::OFFSET_DEFAULT_C, ..Default::default() }
}
pub struct Shared {
pub token: String,
pub state: Mutex<State>,
@ -153,7 +173,8 @@ impl Shared {
st.mining.accepted_total = settings.accepted_total;
st.mining.fee_total = settings.fee_total;
st.address = address_state(&settings, &wallet_path);
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public, ui_builtin: settings.ui_builtin };
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, region: settings.region.clone(), heat_on: settings.heat_on, heat_set_c: settings.heat_set_c, heat_schedule: crate::heat::schedule_text(&settings.heat_schedule), heat_tz_min: settings.heat_tz_min, heat_room_c: settings.heat_room_c, heat_room_at: settings.heat_room_at, heat_offset_c: settings.heat_offset_c, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public, ui_builtin: settings.ui_builtin };
st.heat = heat_state_off(settings.heat_on, settings.heat_set_c);
st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() };
st.live_page = packaged.live_page.clone();
st.finality.message = "waiting for the miner".into();
@ -714,6 +735,18 @@ pub struct Engine {
node_watch: crate::watchdog::NodeWatch,
/// the watchdogs' clock origin (they take seconds)
t0: Instant,
/// Ember Heat (src/heat.rs): the loop, whether the miners are stopped for a rest and since when, the cards'
/// draw the last time they heated, the last hour of (unix s, heating) samples for the duty readout, the last
/// HEAT log line's time, and the typed reading the loop last saw (a new one may teach the idle offset)
heat: crate::heat::Controller,
heat_rest: bool,
heat_rest_since: Option<Instant>,
heat_full_w: f64,
heat_samples: std::collections::VecDeque<(f64, bool)>,
heat_log_at: f64,
heat_room_seen: f64,
/// the last half minute of (unix s, coolest card sensor) for the cooling-tail slope (heat::room)
heat_card_samples: std::collections::VecDeque<(f64, f64)>,
}
impl Engine {
@ -824,6 +857,14 @@ impl Engine {
sweep_attempts: std::collections::HashMap::new(),
node_watch: crate::watchdog::NodeWatch::new(),
t0: now,
heat: crate::heat::Controller::new(),
heat_rest: false,
heat_rest_since: None,
heat_full_w: 0.0,
heat_samples: std::collections::VecDeque::new(),
heat_log_at: 0.0,
heat_room_seen: 0.0,
heat_card_samples: std::collections::VecDeque::new(),
}
}
@ -1367,6 +1408,83 @@ impl Engine {
drop(st);
self.shared.event("info", &format!("tune goal: {}{}{}", s.tune_goal, if s.power_price_pence > 0.0 { format!(", electricity {:.1} p/kWh", s.power_price_pence) } else { String::new() }, if s.tune_climb { ", hill-climb on" } else { "" }));
}
Cmd::Region(region, price) => {
let (r, p) = {
let mut s = self.shared.settings.lock().unwrap();
if let Some(r) = &region {
s.region = r.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').take(16).collect();
}
if let Some(p) = price {
s.power_price_pence = p;
}
(s.region.clone(), s.power_price_pence)
};
self.shared.save_settings();
{
let mut st = self.st();
st.settings.region = r.clone();
st.settings.power_price_pence = p;
}
self.shared.event("info", &format!("electricity: region {}{}", if r.is_empty() { "not chosen".to_string() } else { r }, if p > 0.0 { format!(", {p:.2} per kWh (typed, never fetched)") } else { String::new() }));
}
Cmd::Heat(patch) => {
let mut bad: Option<String> = None;
let (on, set_c, sched, tz, room_c, room_at, was_on) = {
let mut s = self.shared.settings.lock().unwrap();
let was_on = s.heat_on;
if let Some(c) = patch.set_c {
if (crate::heat::SET_MIN_C..=crate::heat::SET_MAX_C).contains(&c) {
s.heat_set_c = c;
} else {
bad = Some(format!("the set point is {:.0} to {:.0} degrees", crate::heat::SET_MIN_C, crate::heat::SET_MAX_C));
}
}
if let Some((text, tz)) = &patch.schedule {
match crate::heat::parse_schedule(text) {
Ok(slots) => {
s.heat_schedule = slots;
s.heat_tz_min = *tz;
}
Err(e) => bad = Some(e),
}
}
if let Some(c) = patch.room_c {
if (-20.0..=50.0).contains(&c) {
s.heat_room_c = c;
s.heat_room_at = crate::platform::unix_now_f();
} else {
bad = Some("a room reading is -20 to 50 degrees".into());
}
}
if let Some(on) = patch.on {
s.heat_on = on;
}
(s.heat_on, s.heat_set_c, crate::heat::schedule_text(&s.heat_schedule), s.heat_tz_min, s.heat_room_c, s.heat_room_at, was_on)
};
self.shared.save_settings();
{
let mut st = self.st();
st.settings.heat_on = on;
st.settings.heat_set_c = set_c;
st.settings.heat_schedule = sched.clone();
st.settings.heat_tz_min = tz;
st.settings.heat_room_c = room_c;
st.settings.heat_room_at = room_at;
}
if let Some(b) = bad {
self.shared.event("error", &format!("heat mode: {b}"));
}
if on != was_on {
self.heat.reset();
self.heat_samples.clear();
self.shared.event("info", &if on { format!("heat mode on: holding {set_c:.1} °C{}; the cards heat for a share of every {} minutes and rest for the rest", if sched.is_empty() { String::new() } else { format!(" (schedule {sched})") }, (crate::heat::PERIOD_S / 60.0) as u64) } else { "heat mode off: the cards mine whenever the node is synced".to_string() });
if !on {
self.heat_release("heat mode off");
}
} else if on {
self.shared.log(&format!("heat: settings set={set_c:.1} schedule=\"{sched}\" tz={tz} room={room_c:.1}@{room_at:.0}"));
}
}
Cmd::TuneSet(seq, r) => match r {
Ok(text) => {
self.tune_acked = Some(seq);
@ -2455,7 +2573,7 @@ impl Engine {
return;
}
self.sweep_next_check = now + Duration::from_secs(10);
if self.quitting || !self.running || self.power_busy || self.job_hold || self.jobs.holds_miners() {
if self.quitting || !self.running || self.power_busy || self.job_hold || self.jobs.holds_miners() || self.heat_rest {
return;
}
let tuning = self.tuning_object();
@ -2999,6 +3117,8 @@ impl Engine {
Some("the app is quitting".to_string())
} else if self.job_hold || self.jobs.holds_miners() {
Some("a remote job took the GPU".into())
} else if self.heat_rest {
Some("heat mode rested the card".into())
} else if self.st().mining.paused {
Some("mining paused".into())
} else if c.state != "mining" && c.state != "tuning" {
@ -3289,6 +3409,7 @@ impl Engine {
self.tick_telemetry(now);
self.tick_sweep(now);
}
self.tick_heat(now);
self.derive(now);
self.tick_balance(now);
if now.duration_since(self.last_status) >= Duration::from_secs(self.shared.runtime.status_secs as u64) {
@ -3420,6 +3541,135 @@ impl Engine {
}
}
// ---- Ember Heat (src/heat.rs) ------------------------------------------------------------------------------
/// The heat loop, every tick: the reading, the decision, the rest or the release, the state and the log line.
fn tick_heat(&mut self, now: Instant) {
let s = self.shared.settings.lock().unwrap().clone();
let unix = crate::platform::unix_now_f();
if !s.heat_on {
if self.heat_rest {
self.heat_release("heat mode off");
}
let mut st = self.st();
if st.heat.on || st.heat.phase != "off" {
st.heat = heat_state_off(false, s.heat_set_c);
}
return;
}
let (cards, paused, synced) = {
let st = self.st();
(st.mining.cards.clone(), st.mining.paused, st.node.synced && st.clock.severity != "block")
};
let present: Vec<&CardState> = cards.iter().filter(|c| c.enabled && c.present()).collect();
// the coolest card with a sensor reading under a minute old stands for the room once the cards have rested
let card_c = present.iter().filter(|c| c.temp_gpu > 0.0 && unix - c.telemetry_at < 60.0).map(|c| c.temp_gpu).fold(0.0f64, |a, b| if a == 0.0 { b } else { a.min(b) });
let rest_for = self.heat_rest_since.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(0.0);
if card_c > 0.0 {
self.heat_card_samples.push_back((unix, card_c));
}
while self.heat_card_samples.front().map(|(t, _)| unix - t > 30.0).unwrap_or(false) {
self.heat_card_samples.pop_front();
}
let card_slope = crate::heat::slope_of(self.heat_card_samples.iter().copied());
// a new typed reading, taken while the cards rest, teaches the idle offset
if s.heat_room_at > 0.0 && s.heat_room_at != self.heat_room_seen {
self.heat_room_seen = s.heat_room_at;
if let Some(off) = crate::heat::learned_offset(card_c, rest_for, s.heat_room_c) {
self.shared.settings.lock().unwrap().heat_offset_c = off;
self.shared.save_settings();
self.st().settings.heat_offset_c = off;
self.shared.log(&format!("heat: idle offset learned: card {card_c:.1} minus room {:.1} = {off:.1} degrees", s.heat_room_c));
}
}
let offset = self.shared.settings.lock().unwrap().heat_offset_c;
let typed = if s.heat_room_at > 0.0 { Some((s.heat_room_c, s.heat_room_at)) } else { None };
let reading = crate::heat::room(unix, typed, card_c, card_slope, rest_for, offset);
let set_c = crate::heat::set_point_at(s.heat_set_c, &s.heat_schedule, crate::heat::minute_of_day(unix, s.heat_tz_min));
let d = self.heat.step(unix, set_c, reading);
if d.new_period {
self.shared.log(&format!("heat: period duty={:.2} set={set_c:.1} room={} src={} integral={:.2}", d.duty, if reading.source == crate::heat::Source::None { "-".to_string() } else { format!("{:.1}", reading.room_c) }, reading.source.name(), self.heat.integral));
}
let can_run = self.running && !paused && !present.is_empty() && !self.job_hold && !self.jobs.holds_miners() && !self.quitting;
if can_run && !d.heating && !self.heat_rest {
self.heat_rest(set_c, &reading);
} else if (d.heating || !can_run) && self.heat_rest {
self.heat_release(if d.heating { "heating again" } else { "the cards are not ours to rest" });
}
let heating_now = cards.iter().any(|c| c.state == "mining");
let heat_w: f64 = cards.iter().filter(|c| c.state == "mining").map(|c| c.power_w.max(0.0)).sum();
if heating_now && heat_w > 0.0 {
self.heat_full_w = heat_w;
}
self.heat_samples.push_back((unix, heating_now));
while self.heat_samples.front().map(|(t, _)| unix - t > 3600.0).unwrap_or(false) {
self.heat_samples.pop_front();
}
let duty_hour = if self.heat_samples.len() > 1 { self.heat_samples.iter().filter(|(_, h)| *h).count() as f64 / self.heat_samples.len() as f64 } else { 0.0 };
let phase = if !self.running || paused { "paused" } else if present.is_empty() { "waiting" } else if self.heat_rest { "resting" } else if !synced && !heating_now { "waiting" } else { "heating" };
let hash: f64 = cards.iter().filter(|c| c.state == "mining").map(|c| c.hash_now).sum();
{
let mut st = self.st();
let h = &mut st.heat;
h.on = true;
h.phase = phase.into();
h.set_c = set_c;
h.room_c = reading.room_c;
h.room_source = reading.source.name().into();
h.room_error_c = reading.error_c;
h.room_age_s = reading.age_s;
h.duty = d.duty;
h.duty_hour = duty_hour;
h.heat_w = heat_w;
h.full_w = self.heat_full_w;
h.heat_avg_w = d.duty * self.heat_full_w;
h.until_s = d.until_s;
h.period_s = crate::heat::PERIOD_S;
h.offset_c = if offset > 0.0 { offset } else { crate::heat::OFFSET_DEFAULT_C };
h.offset_learned = offset > 0.0;
h.note = crate::heat::words(true, phase, set_c, &reading, d.duty, d.until_s);
}
if unix - self.heat_log_at >= crate::heat::LOG_EVERY_S {
self.heat_log_at = unix;
self.shared.log(&crate::heat::log_line(unix, phase, set_c, &reading, d.duty, heat_w, hash, d.until_s));
}
}
/// The rest: the miners stop, the rows say why, nothing restarts them until the release.
fn heat_rest(&mut self, set_c: f64, reading: &crate::heat::Reading) {
self.heat_rest = true;
self.heat_rest_since = Some(Instant::now());
self.stop_miners("heat mode: the room is at the set point");
let msg = match reading.source {
crate::heat::Source::None => format!("resting to read the room, then holding {set_c:.1} °C (heat mode)"),
_ => format!("resting: the room is {:.1} °C, holding {set_c:.1} °C (heat mode)", reading.room_c),
};
for c in self.st().mining.cards.iter_mut().filter(|c| c.enabled && c.present() && c.state != "faulted") {
c.state = "resting".into();
c.message = msg.clone();
}
}
/// The release: the rows go back to off, every slot is re-armed, the workers come back with a fresh pack check.
fn heat_release(&mut self, why: &str) {
self.heat_rest = false;
self.heat_rest_since = None;
for c in self.st().mining.cards.iter_mut().filter(|c| c.state == "resting") {
c.state = "off".into();
c.message = String::new();
}
if self.running {
self.shared.log(&format!("heat: release ({why}); the miners restart"));
let now = Instant::now();
for m in self.miners.iter_mut() {
if m.watch.faulted().is_none() {
m.restart_at = Some(now);
m.prepared = false;
}
}
}
}
fn job_action(&mut self, a: crate::jobrun::Action) {
use crate::jobrun::Action;
match a {
@ -3762,6 +4012,10 @@ impl Engine {
// a remote job has the GPU; the miners wait until it lets go (src/jobrun.rs)
continue;
}
if self.heat_rest {
// heat mode rests the cards until the room wants heat again (src/heat.rs, tick_heat)
continue;
}
if paused || !synced {
if let Some(c) = self.st().mining.cards.get_mut(card_idx) {
if !paused && c.state != "failed" {

487
app/igneum-app/src/heat.rs Normal file
View file

@ -0,0 +1,487 @@
//! Ember Heat (mission item 7, 7 October 2026, docs/plans/ember-heat.md): the card is a heater that also earns. Heat mode
//! holds a room temperature, or a schedule of them, and the hash follows the duty cycle: the cards mine for a share of
//! every ten-minute period and rest for the rest of it. The chain sees a miner with a schedule, nothing else.
//!
//! The temperature source is what the machine has: a reading the miner types from their own thermometer (fresh for
//! two hours), else the card's own sensor once the card has rested long enough to cool to the room plus an idle
//! offset (default 8 degrees, approximate; learned from a typed reading taken while the card rests). No hardware
//! the app does not have. Without any reading the loop heats 70 percent of every period and says so.
//!
//! The loop is proportional plus integral, decided once per period on the reading at the period's start: the
//! integral carries the steady-state share of heat the room needs, the proportional term pulls it back when the
//! room drifts. The engine (src/engine.rs, `tick_heat`) owns the processes: it stops the miners for a rest and
//! re-arms them for the heating slice; nothing here touches a card. The log carries one `HEAT` line every 30 s and
//! tools/heat-gate.mjs reads it for the PC 1 gate (held within 1 degree for 4 hours, hash following the duty).
/// One period: the heating slice first, the rest after it.
pub const PERIOD_S: f64 = 600.0;
/// Duty per degree under the set point (2 degrees under = a full period of heat).
pub const KP: f64 = 0.5;
/// Duty per degree per period added to the integral (the steady-state share).
pub const KI: f64 = 0.05;
/// The card sensor stands for the room only after this long at rest (the die cools toward the room plus the idle
/// offset; what is left of the cooling tail is taken off by its slope, COOL_TAU_S).
pub const SETTLE_S: f64 = 180.0;
/// The cooling tail of a stopped card as one time constant, seconds, approximate: the estimate adds tau times the
/// (negative) slope of the sensor, which is exact for a single exponential and partial otherwise.
pub const COOL_TAU_S: f64 = 60.0;
/// A typed room reading is the room for this long.
pub const TYPED_FRESH_S: f64 = 7200.0;
/// An idle card's sensor above the room, degrees, approximate, until a typed reading teaches the real offset.
pub const OFFSET_DEFAULT_C: f64 = 8.0;
/// The stated error of the card-sensor estimate, degrees.
pub const OFFSET_ERROR_C: f64 = 3.0;
/// With the card sensor as the only source, every period keeps a rest long enough to read the room.
pub const CARD_DUTY_MAX: f64 = 1.0 - SETTLE_S / PERIOD_S;
/// With no reading at all: heat this share of every period (the rest long enough for the card to read the room) and say why.
pub const FIND_DUTY: f64 = CARD_DUTY_MAX;
/// The set point the app accepts, degrees.
pub const SET_MIN_C: f64 = 5.0;
pub const SET_MAX_C: f64 = 30.0;
/// The log line and the gate tool's sample spacing.
pub const LOG_EVERY_S: f64 = 30.0;
/// One entry of a schedule: from this minute of the day the set point is `set_c`, until the next entry.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Slot {
pub minute: u32,
pub set_c: f64,
}
/// "06:00 20, 22:00 16" to slots, sorted by minute. Empty text = no schedule (the one set point all day).
pub fn parse_schedule(text: &str) -> Result<Vec<Slot>, String> {
let mut out = Vec::new();
for part in text.split(|c| c == ',' || c == ';' || c == '\n') {
let part = part.trim();
if part.is_empty() {
continue;
}
let mut it = part.split_whitespace();
let (Some(time), Some(temp)) = (it.next(), it.next()) else {
return Err(format!("\"{part}\": write a time and a temperature, for example 06:00 20"));
};
let (h, m) = time.split_once(':').ok_or_else(|| format!("\"{time}\": a time is HH:MM"))?;
let h: u32 = h.parse().map_err(|_| format!("\"{time}\": a time is HH:MM"))?;
let m: u32 = m.parse().map_err(|_| format!("\"{time}\": a time is HH:MM"))?;
if h > 23 || m > 59 {
return Err(format!("\"{time}\": a time is HH:MM, 00:00 to 23:59"));
}
let set_c: f64 = temp.trim_end_matches("°C").trim_end_matches('C').parse().map_err(|_| format!("\"{temp}\": a temperature is a number of degrees"))?;
if !(SET_MIN_C..=SET_MAX_C).contains(&set_c) {
return Err(format!("{set_c} degrees: the set point is {SET_MIN_C:.0} to {SET_MAX_C:.0}"));
}
out.push(Slot { minute: h * 60 + m, set_c });
}
out.sort_by_key(|s| s.minute);
out.dedup_by_key(|s| s.minute);
Ok(out)
}
/// Slots back to the text the settings page shows.
pub fn schedule_text(slots: &[Slot]) -> String {
slots.iter().map(|s| format!("{:02}:{:02} {}", s.minute / 60, s.minute % 60, trim_c(s.set_c))).collect::<Vec<_>>().join(", ")
}
fn trim_c(c: f64) -> String {
if (c - c.round()).abs() < 0.05 { format!("{:.0}", c) } else { format!("{:.1}", c) }
}
/// The set point in force at a minute of the day: the latest slot at or before it; before the first slot, the last
/// slot of the day (the schedule wraps at midnight). No slots: the default.
pub fn set_point_at(default_c: f64, slots: &[Slot], minute_of_day: u32) -> f64 {
if slots.is_empty() {
return default_c;
}
slots.iter().rev().find(|s| s.minute <= minute_of_day).or_else(|| slots.last()).map(|s| s.set_c).unwrap_or(default_c)
}
/// The minute of the day in the schedule's own clock: unix seconds plus the window's offset east of UTC in minutes.
pub fn minute_of_day(unix: f64, tz_east_min: i32) -> u32 {
let local = unix as i64 + tz_east_min as i64 * 60;
((local.rem_euclid(86_400)) / 60) as u32
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Source {
Typed,
Card,
None,
}
impl Source {
pub fn name(&self) -> &'static str {
match self {
Source::Typed => "typed",
Source::Card => "card",
Source::None => "none",
}
}
}
/// What the loop knows about the room right now.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Reading {
pub room_c: f64,
pub source: Source,
/// the stated error of the estimate, degrees (0 for a typed reading: the thermometer is the miner's)
pub error_c: f64,
/// how old the reading is, seconds
pub age_s: f64,
}
impl Reading {
pub fn none() -> Reading {
Reading { room_c: 0.0, source: Source::None, error_c: 0.0, age_s: 0.0 }
}
}
/// The room estimate: a typed reading while it is fresh, else the coolest card's sensor minus the idle offset once
/// the cards have rested SETTLE_S (the cooling tail still in the sensor taken off by its slope), else nothing.
/// `typed` = (degrees, unix s typed); `card_c` = 0 when no card reports; `card_slope` = degrees per second over the
/// last half minute (negative while cooling; a rising sensor is not corrected).
pub fn room(now: f64, typed: Option<(f64, f64)>, card_c: f64, card_slope: f64, rest_for_s: f64, offset_c: f64) -> Reading {
if let Some((c, at)) = typed {
if c > -50.0 && at > 0.0 && now - at < TYPED_FRESH_S {
return Reading { room_c: c, source: Source::Typed, error_c: 0.0, age_s: (now - at).max(0.0) };
}
}
if card_c > 0.0 && rest_for_s >= SETTLE_S {
let off = if offset_c > 0.0 { offset_c } else { OFFSET_DEFAULT_C };
let tail = COOL_TAU_S * card_slope.min(0.0);
return Reading { room_c: card_c + tail - off, source: Source::Card, error_c: OFFSET_ERROR_C, age_s: 0.0 };
}
Reading::none()
}
/// The slope of sensor samples (unix s, degrees) at the END of the window, degrees per second: the least-squares
/// line (its slope belongs to the window's middle) brought forward by the exponential tail's own decay over half
/// the window. 0 with fewer than two samples.
pub fn slope_of<I: Iterator<Item = (f64, f64)>>(samples: I) -> f64 {
let v: Vec<(f64, f64)> = samples.collect();
if v.len() < 2 {
return 0.0;
}
let n = v.len() as f64;
let (mt, mc) = (v.iter().map(|s| s.0).sum::<f64>() / n, v.iter().map(|s| s.1).sum::<f64>() / n);
let (mut num, mut den) = (0.0, 0.0);
for (t, c) in &v {
num += (t - mt) * (c - mc);
den += (t - mt) * (t - mt);
}
if den <= 0.0 {
return 0.0;
}
let span = v.iter().map(|s| s.0).fold(f64::MIN, f64::max) - v.iter().map(|s| s.0).fold(f64::MAX, f64::min);
num / den * (-(span / 2.0) / COOL_TAU_S).exp()
}
/// A typed reading taken while the cards have rested teaches the idle offset (card minus room, 0 to 20 degrees).
pub fn learned_offset(card_c: f64, rest_for_s: f64, typed_c: f64) -> Option<f64> {
if card_c <= 0.0 || rest_for_s < SETTLE_S {
return None;
}
Some((card_c - typed_c).clamp(0.0, 20.0))
}
/// The loop's memory between ticks.
#[derive(Clone, Debug, PartialEq)]
pub struct Controller {
/// the steady-state share of heat the room needs, 0 to 1 (starts at a half)
pub integral: f64,
pub period_start: f64,
/// this period's duty, 0 to 1
pub duty: f64,
/// this period's heating slice, seconds
pub on_s: f64,
started: bool,
}
/// What the engine does this tick.
#[derive(Clone, Debug, PartialEq)]
pub struct Decision {
pub heating: bool,
pub duty: f64,
pub set_c: f64,
pub reading: Reading,
/// seconds until the slice changes (heating to rest, or the next period)
pub until_s: f64,
/// a new period began on this tick
pub new_period: bool,
}
impl Default for Controller {
fn default() -> Controller {
Controller::new()
}
}
impl Controller {
pub fn new() -> Controller {
Controller { integral: 0.5, period_start: 0.0, duty: 0.0, on_s: 0.0, started: false }
}
/// The duty a reading asks for, and the integral it leaves: the proportional term on the error, the integral on
/// the steady-state share; with no reading the find share and an untouched integral.
pub fn duty_for(&mut self, set_c: f64, reading: &Reading) -> f64 {
match reading.source {
Source::None => FIND_DUTY,
src => {
let err = set_c - reading.room_c;
self.integral = (self.integral + KI * err).clamp(0.0, 1.0);
let d = (KP * err + self.integral).clamp(0.0, 1.0);
if src == Source::Card { d.min(CARD_DUTY_MAX) } else { d }
}
}
}
/// One tick. A new period is decided on the reading at its start; inside a period only the clock moves.
pub fn step(&mut self, now: f64, set_c: f64, reading: Reading) -> Decision {
let mut new_period = false;
if !self.started || now >= self.period_start + PERIOD_S {
new_period = true;
self.started = true;
self.period_start = now;
self.duty = self.duty_for(set_c, &reading);
self.on_s = if self.duty >= 0.999 { PERIOD_S } else if self.duty <= 0.001 { 0.0 } else { (self.duty * PERIOD_S).round() };
}
let heating = now < self.period_start + self.on_s;
let until_s = if heating { self.period_start + self.on_s - now } else { self.period_start + PERIOD_S - now };
Decision { heating, duty: self.duty, set_c, reading, until_s: until_s.max(0.0), new_period }
}
/// Heat mode switched off or on again: the next tick starts a fresh period; the learned share stays.
pub fn reset(&mut self) {
self.started = false;
}
}
/// The `HEAT` log line the gate tool reads (tools/heat-gate.mjs): one every LOG_EVERY_S while heat mode is on.
pub fn log_line(unix: f64, phase: &str, set_c: f64, reading: &Reading, duty: f64, heat_w: f64, hash_mhs: f64, until_s: f64) -> String {
format!(
"HEAT t={} phase={} set={:.1} room={} src={} duty={:.2} heat_w={:.0} hash={:.1} until={:.0}",
unix as u64,
phase,
set_c,
if reading.source == Source::None { "-".to_string() } else { format!("{:.1}", reading.room_c) },
reading.source.name(),
duty,
heat_w,
hash_mhs,
until_s
)
}
/// The one line under the switch: what the loop is doing, in the miner's words.
pub fn words(on: bool, phase: &str, set_c: f64, reading: &Reading, duty: f64, until_s: f64) -> String {
if !on {
return "Off. The cards mine whenever the node is synced.".into();
}
let room = match reading.source {
Source::Typed => format!("room {:.1} °C from your reading {}", reading.room_c, if reading.age_s < 90.0 { "just now".to_string() } else { format!("{} min ago", (reading.age_s / 60.0).round() as u64) }),
Source::Card => format!("room about {:.0} °C from the card's sensor (within {:.0} degrees)", reading.room_c, reading.error_c),
Source::None => "no room reading yet: type one, or the card reads it after 2 minutes of rest".into(),
};
let doing = match phase {
"heating" => format!("heating, {} to go", mins(until_s)),
"resting" => format!("resting, heats again in {}", mins(until_s)),
"paused" => "mining is paused, so nothing heats".into(),
"waiting" => "waiting for the node".into(),
_ => phase.to_string(),
};
format!("Holding {:.1} °C: {}. {}. Heat {:.0}% of the time.", set_c, room, doing, duty * 100.0)
}
fn mins(s: f64) -> String {
let m = (s / 60.0).round() as u64;
if s < 45.0 { "under a minute".into() } else if m <= 1 { "a minute".into() } else { format!("{m} min") }
}
#[cfg(test)]
mod tests {
use super::*;
/// A room as a first-order store: C joules per degree, K watts per degree of loss to the outside, P_full watts
/// from the cards while they heat. The card's die sits offset_c above the room at rest and rise_c more while
/// mining, settling with a one-minute time constant.
struct Sim {
room_c: f64,
out_c: f64,
c_j_per_k: f64,
k_w_per_k: f64,
p_full_w: f64,
card_rise_c: f64,
offset_c: f64,
rise_now: f64,
}
impl Sim {
fn new(room_c: f64) -> Sim {
Sim { room_c, out_c: 10.0, c_j_per_k: 400_000.0, k_w_per_k: 20.0, p_full_w: 300.0, card_rise_c: 40.0, offset_c: 8.0, rise_now: 0.0 }
}
fn tick(&mut self, heating: bool, dt: f64) {
let p = if heating { self.p_full_w } else { 0.0 };
self.room_c += (p - self.k_w_per_k * (self.room_c - self.out_c)) * dt / self.c_j_per_k;
let target = if heating { self.card_rise_c } else { 0.0 };
self.rise_now += (target - self.rise_now) * (1.0 - (-dt / 60.0).exp());
}
fn card_c(&self) -> f64 {
self.room_c + self.offset_c + self.rise_now
}
}
/// Four hours after the first hour, the room stays within a degree of the set point and the hash is on exactly
/// while the slice heats: the gate's shape (docs/plans/ember-heat.md), on a model room with the typed reading
/// refreshed every half hour, as a miner with a thermometer on the desk would do.
#[test]
fn a_typed_reading_holds_the_room_within_a_degree_for_four_hours() {
let mut sim = Sim::new(19.0);
let mut ctl = Controller::new();
let set = 20.0;
let t0 = 1000.0;
let mut t = t0;
let mut typed = (sim.room_c, t0);
let mut worst: f64 = 0.0;
let mut heating_s = 0.0;
let mut hash_on_s = 0.0;
while t < t0 + 5.0 * 3600.0 {
if t - typed.1 >= 1800.0 {
typed = (sim.room_c, t);
}
let r = room(t, Some(typed), sim.card_c(), 0.0, 0.0, 0.0);
assert_eq!(r.source, Source::Typed);
let d = ctl.step(t, set, r);
let hash = if d.heating { 100.0 } else { 0.0 };
sim.tick(d.heating, 1.0);
if t >= t0 + 3600.0 {
worst = worst.max((sim.room_c - set).abs());
if d.heating { heating_s += 1.0; }
if hash > 0.0 { hash_on_s += 1.0; }
}
t += 1.0;
}
assert!(worst < 1.0, "the room left the band: worst {worst:.2} degrees");
assert_eq!(heating_s, hash_on_s, "the hash was on exactly while the slice heated");
// the room needs 20 W per degree over 10 degrees = 200 W of 300: a duty near two thirds
assert!((ctl.integral - 0.667).abs() < 0.15, "the integral found the steady share: {:.2}", ctl.integral);
assert!(heating_s / (4.0 * 3600.0) > 0.5 && heating_s / (4.0 * 3600.0) < 0.85, "the hash followed the duty: {:.2}", heating_s / (4.0 * 3600.0));
}
/// The card's own sensor as the only source: every period keeps three minutes of rest, the reading is taken after
/// that rest with the cooling tail taken off, and the room still holds within a degree over the four hours after
/// the first. The sensor's slope comes from the last half minute of samples, as the engine takes it.
#[test]
fn the_card_sensor_alone_holds_the_room_within_a_degree() {
let mut sim = Sim::new(19.0);
let mut ctl = Controller::new();
let set = 20.0;
let mut t = 0.0;
let mut rest_since: Option<f64> = None;
let mut worst: f64 = 0.0;
let mut estimate_err: f64 = 0.0;
let mut samples: std::collections::VecDeque<(f64, f64)> = std::collections::VecDeque::new();
while t < 5.0 * 3600.0 {
let rest_for = rest_since.map(|s| t - s).unwrap_or(0.0);
samples.push_back((t, sim.card_c()));
while samples.front().map(|(s, _)| t - s > 30.0).unwrap_or(false) { samples.pop_front(); }
let slope = slope_of(samples.iter().copied());
let r = room(t, None, sim.card_c(), slope, rest_for, 0.0);
let d = ctl.step(t, set, r);
if d.new_period {
assert!(d.duty <= CARD_DUTY_MAX + 1e-9, "a card-sensed period keeps its rest: {}", d.duty);
if r.source == Source::Card {
estimate_err = estimate_err.max((r.room_c - sim.room_c).abs());
}
}
sim.tick(d.heating, 1.0);
rest_since = if d.heating { None } else { Some(rest_since.unwrap_or(t)) };
if t >= 3600.0 {
worst = worst.max((sim.room_c - set).abs());
}
t += 1.0;
}
assert!(worst < 1.0, "the room left the band: worst {worst:.2} degrees");
assert!(estimate_err < OFFSET_ERROR_C, "the card estimate stayed inside its stated error: {estimate_err:.2}");
}
/// Without the slope correction the sensor still carries the cooling tail at the end of the rest and the
/// estimate reads high by more than the stated error: the correction is what makes the card path honest.
#[test]
fn the_cooling_tail_is_taken_off_by_the_slope() {
let mut sim = Sim::new(19.0);
for _ in 0..600 { sim.tick(true, 1.0); }
let mut samples: std::collections::VecDeque<(f64, f64)> = std::collections::VecDeque::new();
let mut t = 0.0;
for _ in 0..(SETTLE_S as usize) { sim.tick(false, 1.0); t += 1.0; samples.push_back((t, sim.card_c())); while samples.front().map(|(s, _)| t - s > 30.0).unwrap_or(false) { samples.pop_front(); } }
let slope = slope_of(samples.iter().copied());
let raw = room(t, None, sim.card_c(), 0.0, SETTLE_S, 8.0);
let fixed = room(t, None, sim.card_c(), slope, SETTLE_S, 8.0);
assert!(raw.room_c - sim.room_c > 1.0, "the raw sensor still reads the tail: {:.2} over", raw.room_c - sim.room_c);
assert!((fixed.room_c - sim.room_c).abs() < 0.5, "the corrected estimate is the room: {:.2} off", fixed.room_c - sim.room_c);
}
#[test]
fn without_a_reading_the_loop_heats_the_find_share_and_says_so() {
let mut ctl = Controller::new();
let d = ctl.step(1000.0, 20.0, Reading::none());
assert_eq!(d.duty, FIND_DUTY);
assert!(d.heating);
assert_eq!(ctl.integral, 0.5, "no reading leaves the integral alone");
assert!(words(true, "heating", 20.0, &d.reading, d.duty, d.until_s).contains("no room reading yet"));
// the slice ends at 80% of the period, the rest runs to the period's end
let d2 = ctl.step(1000.0 + FIND_DUTY * PERIOD_S + 1.0, 20.0, Reading::none());
assert!(!d2.heating);
assert!(!d2.new_period);
assert!(d2.until_s <= (1.0 - FIND_DUTY) * PERIOD_S);
}
#[test]
fn the_room_source_order_is_typed_then_card_then_none() {
let typed = Some((19.5, 1000.0));
assert_eq!(room(1500.0, typed, 30.0, 0.0, 300.0, 0.0).source, Source::Typed);
let stale = room(1000.0 + TYPED_FRESH_S + 1.0, typed, 30.0, 0.0, 300.0, 0.0);
assert_eq!(stale.source, Source::Card);
assert!((stale.room_c - 22.0).abs() < 1e-9, "card 30 minus the default offset 8");
assert_eq!(room(1500.0, None, 30.0, 0.0, 60.0, 0.0).source, Source::None, "a card still warm from mining is not the room");
assert_eq!(room(1500.0, None, 0.0, 0.0, 600.0, 0.0).source, Source::None, "no card sensor at all");
assert!((room(1500.0, None, 30.0, 0.02, 300.0, 0.0).room_c - 22.0).abs() < 1e-9, "a rising sensor is not corrected");
let learned = room(1500.0, None, 30.0, 0.0, 300.0, 11.0);
assert!((learned.room_c - 19.0).abs() < 1e-9, "a learned offset replaces the default");
assert_eq!(learned_offset(30.0, 300.0, 19.0), Some(11.0));
assert_eq!(learned_offset(30.0, 30.0, 19.0), None, "a card that has not rested teaches nothing");
}
#[test]
fn a_schedule_parses_sorts_and_wraps_at_midnight() {
let s = parse_schedule("22:00 16, 06:00 20").unwrap();
assert_eq!(s, vec![Slot { minute: 360, set_c: 20.0 }, Slot { minute: 1320, set_c: 16.0 }]);
assert_eq!(schedule_text(&s), "06:00 20, 22:00 16");
assert_eq!(set_point_at(19.0, &s, 7 * 60), 20.0);
assert_eq!(set_point_at(19.0, &s, 23 * 60), 16.0);
assert_eq!(set_point_at(19.0, &s, 2 * 60), 16.0, "before the first slot the last one of the day holds");
assert_eq!(set_point_at(19.0, &[], 2 * 60), 19.0);
assert!(parse_schedule("06:00").is_err());
assert!(parse_schedule("25:00 20").is_err());
assert!(parse_schedule("06:00 40").is_err(), "the set point is 5 to 30");
assert_eq!(parse_schedule("").unwrap(), vec![]);
assert_eq!(minute_of_day(0.0, 60), 60, "one hour east of UTC at midnight UTC is 01:00");
assert_eq!(minute_of_day(0.0, -300), 19 * 60, "five hours west wraps to the evening before");
}
#[test]
fn the_log_line_carries_what_the_gate_reads() {
let r = Reading { room_c: 19.6, source: Source::Typed, error_c: 0.0, age_s: 30.0 };
let l = log_line(1_759_800_000.0, "heating", 20.0, &r, 0.6, 180.0, 74.2, 312.0);
assert_eq!(l, "HEAT t=1759800000 phase=heating set=20.0 room=19.6 src=typed duty=0.60 heat_w=180 hash=74.2 until=312");
let l2 = log_line(1.0, "resting", 20.0, &Reading::none(), 0.8, 0.0, 0.0, 10.0);
assert!(l2.contains("room=- src=none"));
}
#[test]
fn the_words_name_the_source_the_slice_and_the_share() {
let r = Reading { room_c: 19.6, source: Source::Typed, error_c: 0.0, age_s: 2400.0 };
assert_eq!(words(true, "heating", 20.0, &r, 0.6, 312.0), "Holding 20.0 °C: room 19.6 °C from your reading 40 min ago. heating, 5 min to go. Heat 60% of the time.");
let c = Reading { room_c: 19.0, source: Source::Card, error_c: 3.0, age_s: 0.0 };
assert_eq!(words(true, "resting", 20.0, &c, 0.5, 100.0), "Holding 20.0 °C: room about 19 °C from the card's sensor (within 3 degrees). resting, heats again in 2 min. Heat 50% of the time.");
assert!(words(false, "off", 20.0, &c, 0.0, 0.0).starts_with("Off."));
}
}

View file

@ -37,6 +37,7 @@ mod verifier;
mod wslhost;
mod sweep;
mod ember;
mod heat;
mod powertask;
mod watchdog;
mod live;

View file

@ -313,6 +313,37 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
shared.send(Cmd::TuneGoal(goal, price, climb));
Ok(json!({ "ok": true }))
}
"/api/region" => {
// Ember Heat: the region code and the typed price per kWh in that region's minor unit (never fetched)
let region = body.get("region").and_then(|v| v.as_str()).map(|r| r.to_string());
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=500.0).contains(p));
shared.send(Cmd::Region(region, price));
Ok(json!({ "ok": true }))
}
"/api/heat" => {
// Ember Heat: the switch, the set point, the schedule with the window's clock offset, a typed room reading
let patch = crate::engine::HeatPatch {
on: body.get("on").and_then(|v| v.as_bool()),
set_c: body.get("set_c").and_then(|v| v.as_f64()),
schedule: body.get("schedule").and_then(|v| v.as_str()).map(|t| (t.to_string(), body.get("tz_min").and_then(|v| v.as_i64()).unwrap_or(0).clamp(-840, 840) as i32)),
room_c: body.get("room_c").and_then(|v| v.as_f64()),
};
if let Some(c) = patch.set_c {
if !(crate::heat::SET_MIN_C..=crate::heat::SET_MAX_C).contains(&c) {
return Err(format!("the set point is {:.0} to {:.0} degrees", crate::heat::SET_MIN_C, crate::heat::SET_MAX_C));
}
}
if let Some((t, _)) = &patch.schedule {
crate::heat::parse_schedule(t)?;
}
if let Some(c) = patch.room_c {
if !(-20.0..=50.0).contains(&c) {
return Err("a room reading is -20 to 50 degrees".into());
}
}
shared.send(Cmd::Heat(patch));
Ok(json!({ "ok": true }))
}
"/api/settings" => {
let identities = body.get("identities").and_then(|v| v.as_u64()).map(|v| v.clamp(1, 64) as u32);
let vote = body.get("vote").and_then(|v| v.as_bool());

View file

@ -354,6 +354,16 @@ pub struct SettingsState {
pub tune_goal: String,
pub power_price_pence: f64,
pub tune_climb: bool,
/// Ember Heat (src/heat.rs, config.rs): the region code, the heat-mode switch, the set point, the schedule as
/// the settings page types it, the window's clock offset, the typed room reading and the learned idle offset
pub region: String,
pub heat_on: bool,
pub heat_set_c: f64,
pub heat_schedule: String,
pub heat_tz_min: i32,
pub heat_room_c: f64,
pub heat_room_at: f64,
pub heat_offset_c: f64,
/// the manifest's tune period in seconds (tuning.ember.period_s, default 7 days): a card is due again at
/// sweep_at + tune_period_s, or at once after a driver major or program-class change
pub tune_period_s: u64,
@ -391,6 +401,37 @@ pub struct UiState {
pub note: String,
}
/// Ember Heat (src/heat.rs): what the loop is doing, for the Cards strip, the Settings line and the Overview button.
#[derive(Clone, Serialize, Default)]
pub struct HeatState {
pub on: bool,
/// off | waiting | paused | heating | resting
pub phase: String,
/// the set point in force now (the schedule's slot, or the one set point)
pub set_c: f64,
/// the room estimate and where it came from: typed | card | none; the stated error; the reading's age
pub room_c: f64,
pub room_source: String,
pub room_error_c: f64,
pub room_age_s: f64,
/// this period's duty (0 to 1) and the share of the last hour the cards heated
pub duty: f64,
pub duty_hour: f64,
/// watts of heat now (the mining cards' draw; 0 while resting or with no draw reading), the cards' draw when
/// they heat (the last reading), and the period's average (duty times that)
pub heat_w: f64,
pub full_w: f64,
pub heat_avg_w: f64,
/// seconds until the slice changes
pub until_s: f64,
/// the one line under the switch
pub note: String,
pub period_s: f64,
/// the idle offset in use and whether a typed reading taught it
pub offset_c: f64,
pub offset_learned: bool,
}
/// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip.
#[derive(Clone, Serialize, Default)]
pub struct JobHistory {
@ -498,6 +539,7 @@ pub struct State {
pub clock: ClockState,
pub address: AddressState,
pub settings: SettingsState,
pub heat: HeatState,
pub dev_fee: DevFeeState,
pub update: UpdateState,
pub ui: UiState,