diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 8f28044a4..7362510b5 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -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, + #[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 } } } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index a1c213cf4..28117e62b 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -93,6 +93,10 @@ pub enum Cmd { TuneGoal(Option, Option, Option), /// 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, Option), + /// 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, + pub set_c: Option, + /// 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, +} + +/// 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, @@ -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, + 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) = ®ion { + 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 = 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" { diff --git a/app/igneum-app/src/heat.rs b/app/igneum-app/src/heat.rs new file mode 100644 index 000000000..34c6f6186 --- /dev/null +++ b/app/igneum-app/src/heat.rs @@ -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, 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::>().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>(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::() / n, v.iter().map(|s| s.1).sum::() / 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 { + 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 = 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.")); + } +} diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index 63f56a833..4e5bc916e 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -37,6 +37,7 @@ mod verifier; mod wslhost; mod sweep; mod ember; +mod heat; mod powertask; mod watchdog; mod live; diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index ee183e0e6..690be41bf 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -313,6 +313,37 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result { + // 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()); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index 45d1aeea4..02b9c235d 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -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,