Ember for Miner UI 4 (0.3.16): a per-card goal (POST /api/tune/goal {key, goal}; CardPref.tune_goal; card.tune_goal, empty = global; ember::goal_for at the tune's start) and the untuned point of the last full plan on the card (tune_before_watts, tune_before_mhs, kept across confirm plans) so the row can read the saving; tests; next-cut note

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-06 18:42:40 +00:00
parent 0b48c028f3
commit 441d1eaf88
7 changed files with 74 additions and 1 deletions

View file

@ -39,6 +39,15 @@ pub struct CardPref {
pub sweep_class: String, pub sweep_class: String,
#[serde(default)] #[serde(default)]
pub sweep_source: String, pub sweep_source: String,
/// Miner UI 4 (6 October 2026): this card's goal (efficiency | balanced | rate); empty = the global tune_goal
#[serde(default)]
pub tune_goal: String,
/// the untuned point the last FULL plan measured first (its step 0), kept across confirm plans so the row can
/// read "saves 84 W, 0.15% of rate"; 0 = never measured
#[serde(default)]
pub sweep_before_watts: f64,
#[serde(default)]
pub sweep_before_mhs: f64,
/// Ember 2: the memory clock the last tune chose (0 = the driver's default) /// Ember 2: the memory clock the last tune chose (0 = the driver's default)
#[serde(default)] #[serde(default)]
pub sweep_mem_mhz: u32, pub sweep_mem_mhz: u32,

View file

@ -906,6 +906,11 @@ pub fn tuned_line(mhs: f64, watts: f64, eff: f64) -> String {
format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)") format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)")
} }
/// Miner UI 4 (6 October 2026): a card's own goal when set, else the global one.
pub fn goal_for(card_goal: &str, global: &str) -> Goal {
Goal::parse(if card_goal.trim().is_empty() { global } else { card_goal })
}
/// (D) May a step be SET on this card? Measure-only (no control: Apple, NVIDIA without Power control, an AMD card /// (D) May a step be SET on this card? Measure-only (no control: Apple, NVIDIA without Power control, an AMD card
/// whose probe gave no tune line) sets nothing: the run notices the missing acknowledgement and measures. An AMD /// whose probe gave no tune line) sets nothing: the run notices the missing acknowledgement and measures. An AMD
/// card also needs its ADLX ordinal. 6 October 2026, PC 1 17:57Z: a measure-only 9070 XT was sent a set, refused. /// card also needs its ADLX ordinal. 6 October 2026, PC 1 17:57Z: a measure-only 9070 XT was sent a set, refused.
@ -1029,6 +1034,14 @@ mod tests {
assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0)); assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0));
} }
#[test]
fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() {
assert_eq!(goal_for("", "balanced"), Goal::Balanced);
assert_eq!(goal_for("rate", "balanced"), Goal::MaxRate);
assert_eq!(goal_for("efficiency", "rate"), Goal::Efficiency);
assert_eq!(goal_for(" ", "efficiency"), Goal::Efficiency);
}
#[test] #[test]
fn a_measure_only_card_is_never_set_and_a_back_off_says_when_the_retry_comes() { fn a_measure_only_card_is_never_set_and_a_back_off_says_when_the_retry_comes() {
assert!(!may_set("amd", false, 1), "the probe gave no tune line: measure only"); assert!(!may_set("amd", false, 1), "the probe gave no tune line: measure only");

View file

@ -77,6 +77,8 @@ pub enum Cmd {
TuneProgress(Value), TuneProgress(Value),
/// Ember 2: Settings > goal, electricity price, the hill-climb switch /// Ember 2: Settings > goal, electricity price, the hill-climb switch
TuneGoal(Option<String>, Option<f64>, Option<bool>), TuneGoal(Option<String>, Option<f64>, Option<bool>),
/// one card's goal override (key, goal; "" = back to the global goal)
TuneCardGoal(String, String),
/// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the /// 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 /// prover found a host that was not there when the node started
RestartNode(String), RestartNode(String),
@ -1147,6 +1149,26 @@ impl Engine {
} }
} }
Cmd::TuneProgress(v) => self.tune_progress(&v), Cmd::TuneProgress(v) => self.tune_progress(&v),
Cmd::TuneCardGoal(key, goal) => {
{
let mut s = self.shared.settings.lock().unwrap();
let (enabled, identities) = self.st().mining.cards.iter().find(|c| c.key == key).map(|c| (c.enabled, c.identities)).unwrap_or((true, 1));
let e = s.cards.entry(key.clone()).or_insert_with(|| crate::config::CardPref { enabled, identities, ..Default::default() });
e.tune_goal = goal.clone();
}
self.shared.save_settings();
let name = {
let mut st = self.st();
let c = st.mining.cards.iter_mut().find(|c| c.key == key);
c.map(|c| {
c.tune_goal = goal.clone();
c.name.clone()
})
};
if let Some(name) = name {
self.shared.event("info", &format!("{name}: tune goal {}", if goal.is_empty() { "follows the global setting".to_string() } else { goal.clone() }));
}
}
Cmd::TuneGoal(goal, price, climb) => { Cmd::TuneGoal(goal, price, climb) => {
{ {
let mut s = self.shared.settings.lock().unwrap(); let mut s = self.shared.settings.lock().unwrap();
@ -2414,7 +2436,8 @@ impl Engine {
let full_due = self.tune_full_due.remove(&idx); let full_due = self.tune_full_due.remove(&idx);
let (goal, climb_on) = { let (goal, climb_on) = {
let s = self.shared.settings.lock().unwrap(); let s = self.shared.settings.lock().unwrap();
(crate::ember::Goal::parse(&s.tune_goal), s.tune_climb) // the card's own goal when set, else the global one
(crate::ember::goal_for(s.cards.get(&c.key).map(|p| p.tune_goal.as_str()).unwrap_or(""), &s.tune_goal), s.tune_climb)
}; };
let plan = if let Some(why) = control.as_ref() { let plan = if let Some(why) = control.as_ref() {
if let Some(cc) = self.st().mining.cards.get_mut(idx) { if let Some(cc) = self.st().mining.cards.get_mut(idx) {
@ -2838,6 +2861,14 @@ impl Engine {
e.sweep_driver = c.driver.clone(); e.sweep_driver = c.driver.clone();
e.sweep_class = c.program_class.clone(); e.sweep_class = c.program_class.clone();
e.sweep_source = kind.name().into(); e.sweep_source = kind.name().into();
if kind == crate::ember::PlanKind::Full {
if let Some(b) = run.rows.first() {
e.sweep_before_watts = b.watts;
e.sweep_before_mhs = b.mhs;
c.tune_before_watts = b.watts;
c.tune_before_mhs = b.mhs;
}
}
if !c.pinned && kind != crate::ember::PlanKind::Baseline { if !c.pinned && kind != crate::ember::PlanKind::Baseline {
e.power_pct = row.point.power_pct; e.power_pct = row.point.power_pct;
} }

View file

@ -167,6 +167,9 @@ pub fn apply_pref(c: &mut CardState, p: &CardPref) {
c.mem_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_mem_mhz }; c.mem_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_mem_mhz };
c.clock_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_clock_mhz }; c.clock_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_clock_mhz };
c.tune_source = p.sweep_source.clone(); c.tune_source = p.sweep_source.clone();
c.tune_goal = p.tune_goal.clone();
c.tune_before_watts = p.sweep_before_watts;
c.tune_before_mhs = p.sweep_before_mhs;
if p.sweep_mhs > 0.0 && p.sweep_watts > 0.0 { if p.sweep_mhs > 0.0 && p.sweep_watts > 0.0 {
c.tune_line = crate::ember::tuned_line(p.sweep_mhs, p.sweep_watts, p.sweep_eff); c.tune_line = crate::ember::tuned_line(p.sweep_mhs, p.sweep_watts, p.sweep_eff);
} }

View file

@ -256,6 +256,14 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
let goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string()); let goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string());
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=500.0).contains(p)); let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=500.0).contains(p));
let climb = body.get("climb").and_then(|v| v.as_bool()); let climb = body.get("climb").and_then(|v| v.as_bool());
// with "key": that card's goal override ("" or "global" clears it); without: the global goal
let key = body.get("key").and_then(|v| v.as_str()).map(|k| k.to_string());
if let Some(k) = key {
let g = body.get("goal").and_then(|v| v.as_str()).unwrap_or("");
let g = if g.is_empty() || g == "global" { String::new() } else { crate::ember::Goal::parse(g).name().to_string() };
shared.send(Cmd::TuneCardGoal(k, g));
return Ok(json!({ "ok": true }));
}
shared.send(Cmd::TuneGoal(goal, price, climb)); shared.send(Cmd::TuneGoal(goal, price, climb));
Ok(json!({ "ok": true })) Ok(json!({ "ok": true }))
} }

View file

@ -126,6 +126,9 @@ pub struct CardState {
pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked) pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked)
pub tune_source: String, // full | confirm | baseline pub tune_source: String, // full | confirm | baseline
pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned
pub tune_goal: String, // this card's goal override (efficiency | balanced | rate); "" = the global goal
pub tune_before_watts: f64, // the untuned point of the last full plan (step 0); 0 = never measured
pub tune_before_mhs: f64,
// a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress // a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress
// (the project lead, 6 October 2026: "don't we need to show in the app that tuning is in progress?") // (the project lead, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
pub tune_step: u32, pub tune_step: u32,

View file

@ -314,6 +314,12 @@ What a user on 0.3.14 sees: Power control on, no prompt, and no card tunes (the
## 8a. Next-cut notes (for the 0.3.12 shipper) ## 8a. Next-cut notes (for the 0.3.12 shipper)
For 0.3.16 (Miner UI 4's Cards tab, Ember as its second layer), on ember-tune past the 0.3.15 tip 7fadd4c:
| Commit | What | Where |
|---|---|---|
| (past 7fadd4c) | a per-card goal: `POST /api/tune/goal {key, goal}` (efficiency, balanced, rate; "" or "global" clears), persisted as `CardPref.tune_goal`, echoed as `card.tune_goal` ("" = the global goal), resolved at the tune's start (`ember::goal_for`); the untuned point of the last full plan on the row: `card.tune_before_watts`, `card.tune_before_mhs` (its step 0, kept across confirm plans; `CardPref.sweep_before_*`), so the row can read "saves 84 W, 0.15% of rate" against `sweep_watts` / `sweep_mhs`; Retune = `POST /api/sweep/start {key}` (forced: clears a back-off since 0.3.15) | ember.rs, engine.rs, server.rs, config.rs, state.rs, hotplug.rs + test |
For the 0.3.15 cut (6 October 2026 evening), on ember-tune: For the 0.3.15 cut (6 October 2026 evening), on ember-tune:
| Commit | What | Where | | Commit | What | Where |