diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 9746985a..d523d6db 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -39,6 +39,15 @@ pub struct CardPref { pub sweep_class: String, #[serde(default)] 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) #[serde(default)] pub sweep_mem_mhz: u32, diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index 60b4325b..113751ef 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -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)") } +/// 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 /// 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. @@ -1029,6 +1034,14 @@ mod tests { 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] 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"); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index abbb3f79..70545e3f 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -77,6 +77,8 @@ pub enum Cmd { TuneProgress(Value), /// Ember 2: Settings > goal, electricity price, the hill-climb switch TuneGoal(Option, Option, Option), + /// 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 /// prover found a host that was not there when the node started RestartNode(String), @@ -1147,6 +1149,26 @@ impl Engine { } } 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) => { { let mut s = self.shared.settings.lock().unwrap(); @@ -2414,7 +2436,8 @@ impl Engine { let full_due = self.tune_full_due.remove(&idx); let (goal, climb_on) = { 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() { 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_class = c.program_class.clone(); 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 { e.power_pct = row.point.power_pct; } diff --git a/app/igneum-app/src/hotplug.rs b/app/igneum-app/src/hotplug.rs index 588e93e0..c5259f82 100644 --- a/app/igneum-app/src/hotplug.rs +++ b/app/igneum-app/src/hotplug.rs @@ -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.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_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 { c.tune_line = crate::ember::tuned_line(p.sweep_mhs, p.sweep_watts, p.sweep_eff); } diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 95984e60..99055a32 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -256,6 +256,14 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result