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:
parent
bb3649a266
commit
38486b01f8
6 changed files with 842 additions and 3 deletions
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) = ®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<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
487
app/igneum-app/src/heat.rs
Normal 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."));
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ mod verifier;
|
|||
mod wslhost;
|
||||
mod sweep;
|
||||
mod ember;
|
||||
mod heat;
|
||||
mod powertask;
|
||||
mod watchdog;
|
||||
mod live;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue