Merge ember-tiers-25 04e5b25a into tiers-25 (the engine half: tiers[], tier, tier_note, tier_at, settings.tune_tier, POST /api/tune/tier, sweep on at balanced from install)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
290d4e1d03
5 changed files with 286 additions and 2 deletions
|
|
@ -45,6 +45,11 @@ pub struct CardPref {
|
|||
/// Miner UI 4 (6 October 2026): this card's goal (efficiency | balanced | rate); empty = the global tune_goal
|
||||
#[serde(default)]
|
||||
pub tune_goal: String,
|
||||
/// Ember Tune tiers (8 October 2026): this card's tier in force and when it was applied
|
||||
#[serde(default)]
|
||||
pub tier: String,
|
||||
#[serde(default)]
|
||||
pub tier_at: u64,
|
||||
/// 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)]
|
||||
|
|
@ -110,6 +115,9 @@ pub struct Settings {
|
|||
/// reading on each card, and the hill-climb switch (memory up, core down from the fleet prior; off = the ladders).
|
||||
#[serde(default = "balanced")]
|
||||
pub tune_goal: String,
|
||||
/// Ember Tune tiers: the fleet tier (efficiency | balanced | max); balanced from install
|
||||
#[serde(default = "default_tier")]
|
||||
pub tune_tier: String,
|
||||
#[serde(default)]
|
||||
pub power_price_pence: f64,
|
||||
#[serde(default)]
|
||||
|
|
@ -213,9 +221,13 @@ fn yes() -> bool {
|
|||
true
|
||||
}
|
||||
|
||||
fn default_tier() -> String {
|
||||
"balanced".into()
|
||||
}
|
||||
|
||||
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, region: String::new(), currency: String::new(), network: 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, prove_instead: 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(), tune_tier: "balanced".into(), power_price_pence: 0.0, tune_climb: false, region: String::new(), currency: String::new(), network: 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, prove_instead: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -621,6 +621,73 @@ pub fn choose(rows: &[Row], tolerance_pct: f64) -> Option<Row> {
|
|||
best.cloned()
|
||||
}
|
||||
|
||||
/// Ember Tune tiers (8 October 2026, the founder's definition relayed by main): the card is tuned once (the search at
|
||||
/// first steady mining, weekly, or on Tune) and the three tiers are rows of THAT search, applied at once with no
|
||||
/// re-search. `efficiency` is the best MH per watt row outright; `balanced` is the best MH per watt row within one
|
||||
/// percent of the top rate (the default from install); `max` is the top-rate row. `source` is "stock" when the row
|
||||
/// is the unlocked 100 percent point (the search's first row), else "measured". Nothing is interpolated.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Tier {
|
||||
pub id: &'static str,
|
||||
pub row: Row,
|
||||
pub source: &'static str,
|
||||
}
|
||||
|
||||
impl Tier {
|
||||
pub fn json(&self) -> serde_json::Value {
|
||||
serde_json::json!({ "id": self.id, "clock_mhz": self.row.point.clock_mhz, "power_pct": self.row.point.power_pct, "w": r1(self.row.watts), "mhs": r2(self.row.mhs), "mhw": r4(self.row.eff), "source": self.source, "limit_w": r1(self.row.limit), "mem_mhz": self.row.point.mem_mhz })
|
||||
}
|
||||
}
|
||||
|
||||
pub const TIER_IDS: [&str; 3] = ["efficiency", "balanced", "max"];
|
||||
pub const BALANCED_TOLERANCE_PCT: f64 = 1.0;
|
||||
|
||||
fn is_stock(r: &Row) -> bool {
|
||||
r.point.clock_mhz == 0 && r.point.power_pct >= 100 && r.point.mem_mhz == 0
|
||||
}
|
||||
|
||||
/// The three tiers of a search, or none when no row is usable. With one usable row all three name it.
|
||||
pub fn tiers(rows: &[Row]) -> Vec<Tier> {
|
||||
let usable: Vec<&Row> = rows.iter().filter(|r| r.usable()).collect();
|
||||
if usable.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let efficiency = usable.iter().copied().fold(None::<&Row>, |b, r| match b { Some(b) if b.eff >= r.eff => Some(b), _ => Some(r) }).cloned().unwrap();
|
||||
let max = usable.iter().copied().fold(None::<&Row>, |b, r| match b { Some(b) if b.mhs >= r.mhs => Some(b), _ => Some(r) }).cloned().unwrap();
|
||||
let balanced = choose(rows, BALANCED_TOLERANCE_PCT).unwrap_or_else(|| efficiency.clone());
|
||||
let src = |r: &Row| if is_stock(r) { "stock" } else { "measured" };
|
||||
vec![
|
||||
Tier { id: "efficiency", source: src(&efficiency), row: efficiency },
|
||||
Tier { id: "balanced", source: src(&balanced), row: balanced },
|
||||
Tier { id: "max", source: src(&max), row: max },
|
||||
]
|
||||
}
|
||||
|
||||
/// The one tier of a card with no lever: its stock row, and the reason in the note.
|
||||
pub fn stock_tier(row: &Row) -> Vec<Tier> {
|
||||
vec![Tier { id: "max", source: "stock", row: row.clone() }]
|
||||
}
|
||||
|
||||
/// The tier's point and cap back from the card state's json (what /api/tune/tier applies with no re-search).
|
||||
pub fn tier_from_json(tiers: &[serde_json::Value], id: &str) -> Option<(Point, f64, f64, f64)> {
|
||||
let t = tiers.iter().find(|t| t.get("id").and_then(|v| v.as_str()) == Some(id))?;
|
||||
let n = |k: &str| t.get(k).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
Some((Point { clock_mhz: n("clock_mhz") as u32, power_pct: (n("power_pct") as u32).clamp(50, 100), mem_mhz: n("mem_mhz") as u32 }, n("limit_w"), n("mhs"), n("w")))
|
||||
}
|
||||
|
||||
/// The row a tier name points at in a tier list.
|
||||
pub fn tier_row(tiers: &[Tier], id: &str) -> Option<Row> {
|
||||
tiers.iter().find(|t| t.id == id).map(|t| t.row.clone())
|
||||
}
|
||||
|
||||
/// The first-run line the strip shows: "Ember Tune set <card> to Balanced: 122.1 MH/s at 221 W, 77 W under stock".
|
||||
pub fn tier_set_line(card: &str, id: &str, row: &Row, stock_w: f64) -> String {
|
||||
let mut c = id.chars();
|
||||
let title = match c.next() { Some(f) => f.to_uppercase().collect::<String>() + c.as_str(), None => String::new() };
|
||||
let saving = if stock_w > row.watts { format!(", {:.0} W under stock", stock_w - row.watts) } else { String::new() };
|
||||
format!("Ember Tune set {card} to {title}: {:.1} MH/s at {:.0} W{saving}", row.mhs, row.watts)
|
||||
}
|
||||
|
||||
/// A confirm check's verdict: the prior stands, or the neighbour beat it by over CONFIRM_GAIN_PCT (the full plan is
|
||||
/// due), or nothing could be read.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
|
@ -1843,4 +1910,77 @@ mod tests {
|
|||
assert_eq!(run.rows[2].mark, Some(Mark::Faulted));
|
||||
assert_eq!(run.plan.clock_stop_reason(&run.rows).as_deref(), Some("fingerprint mismatch at 2163 MHz, clocks reset"));
|
||||
}
|
||||
|
||||
// ---- tiers (8 October 2026): the 5090 and 5080 curves of the night of 7 to 8 October as fixtures ----
|
||||
fn curve_row(clock: u32, pct: u32, watts: f64, mhs: f64) -> Row {
|
||||
Row { point: Point { clock_mhz: clock, power_pct: pct, mem_mhz: 0 }, limit: watts, watts, mhs, eff: mhs / watts, draws: 12, rates: 6, gclk: clock as f64, mclk: 0.0, tmax: 60.0, faults: 0, mark: Some(Mark::Ok) }
|
||||
}
|
||||
/// PC 1's RTX 5090, the app's stored full curve (8 October 2026, 06:08Z read): stock 122.71 at 298.4 W, the clock
|
||||
/// ladder 2,781 down to 1,390 MHz
|
||||
fn curve_5090() -> Vec<Row> {
|
||||
vec![
|
||||
curve_row(0, 100, 298.4, 122.71), curve_row(0, 90, 301.0, 122.69), curve_row(0, 80, 301.5, 122.53), curve_row(0, 70, 302.3, 123.05), curve_row(0, 60, 302.7, 122.29),
|
||||
curve_row(2781, 100, 292.7, 122.16), curve_row(2472, 100, 255.2, 122.4), curve_row(2163, 100, 231.1, 121.06), curve_row(1854, 100, 221.0, 122.06), curve_row(1545, 100, 213.7, 120.91), curve_row(1390, 100, 204.3, 118.56),
|
||||
]
|
||||
}
|
||||
#[test]
|
||||
fn tiers_of_the_5090_curve_are_three_measured_rows_of_that_search() {
|
||||
let t = tiers(&curve_5090());
|
||||
assert_eq!(t.len(), 3);
|
||||
// efficiency: the best MH/W outright is the 1,390 MHz row (0.5803)
|
||||
assert_eq!((t[0].id, t[0].row.point.clock_mhz, t[0].source), ("efficiency", 1390, "measured"));
|
||||
// max: the top rate is the 70 percent cap row (123.05), a measured row, not stock
|
||||
assert_eq!((t[2].id, t[2].row.point.power_pct, t[2].row.point.clock_mhz, t[2].source), ("max", 70, 0, "measured"));
|
||||
// balanced: within 1 percent of 123.05 (121.82 and up) the best MH/W is 1,854 MHz (0.5522)
|
||||
assert_eq!((t[1].id, t[1].row.point.clock_mhz, t[1].source), ("balanced", 1854, "measured"));
|
||||
assert!(t[1].row.mhs >= 0.99 * t[2].row.mhs);
|
||||
assert!(t[0].row.eff >= t[1].row.eff && t[1].row.eff >= t[2].row.eff);
|
||||
}
|
||||
#[test]
|
||||
fn the_max_tier_reads_stock_when_the_unlocked_row_is_the_top() {
|
||||
// PC 1's RTX 5080 climb (06:05Z): the stock row at 80 percent is not the stock point; a synthetic stock row on top
|
||||
let rows = vec![curve_row(0, 100, 160.0, 61.0), curve_row(2936, 80, 123.2, 60.27), curve_row(0, 80, 149.8, 60.13)];
|
||||
let t = tiers(&rows);
|
||||
assert_eq!((t[2].id, t[2].source, t[2].row.watts), ("max", "stock", 160.0));
|
||||
assert_eq!((t[0].id, t[0].row.point.clock_mhz, t[0].source), ("efficiency", 2936, "measured"));
|
||||
// balanced: 60.27 is within 1 percent of 61.0 (60.39)? no: 60.27 < 60.39, so balanced is the stock row itself
|
||||
assert_eq!((t[1].id, t[1].source), ("balanced", "stock"));
|
||||
}
|
||||
#[test]
|
||||
fn no_usable_row_gives_no_tier_and_one_row_names_all_three() {
|
||||
assert!(tiers(&[]).is_empty());
|
||||
let mut bad = curve_row(0, 100, 300.0, 120.0);
|
||||
bad.mark = Some(Mark::Faulted);
|
||||
assert!(tiers(&[bad]).is_empty());
|
||||
let one = vec![curve_row(1854, 100, 221.0, 122.06)];
|
||||
let t = tiers(&one);
|
||||
assert_eq!(t.iter().map(|x| x.id).collect::<Vec<_>>(), vec!["efficiency", "balanced", "max"]);
|
||||
assert!(t.iter().all(|x| x.row.point.clock_mhz == 1854 && x.source == "measured"));
|
||||
assert_eq!(tier_row(&t, "balanced").unwrap().point.clock_mhz, 1854);
|
||||
assert!(tier_row(&t, "nothing").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn a_tier_read_back_from_the_card_state_json_is_the_row_that_was_written() {
|
||||
let t = tiers(&curve_5090());
|
||||
let js: Vec<serde_json::Value> = t.iter().map(|x| x.json()).collect();
|
||||
let (p, limit, mhs, w) = tier_from_json(&js, "efficiency").unwrap();
|
||||
assert_eq!((p.clock_mhz, p.power_pct, p.mem_mhz), (1390, 100, 0));
|
||||
assert_eq!((limit, mhs, w), (204.3, 118.56, 204.3));
|
||||
assert!(tier_from_json(&js, "nothing").is_none());
|
||||
assert!(tier_from_json(&[], "balanced").is_none());
|
||||
}
|
||||
#[test]
|
||||
fn the_tier_json_and_the_set_line_carry_the_fields_the_ui_reads() {
|
||||
let t = tiers(&curve_5090());
|
||||
let j = t[1].json();
|
||||
for k in ["id", "clock_mhz", "power_pct", "w", "mhs", "mhw", "source"] {
|
||||
assert!(j.get(k).is_some(), "{k} missing");
|
||||
}
|
||||
assert_eq!(j["w"], 221.0);
|
||||
assert_eq!(j["mhs"], 122.06);
|
||||
assert_eq!(tier_set_line("NVIDIA GeForce RTX 5090", "balanced", &t[1].row, 298.4), "Ember Tune set NVIDIA GeForce RTX 5090 to Balanced: 122.1 MH/s at 221 W, 77 W under stock");
|
||||
assert_eq!(tier_set_line("card", "max", &t[2].row, 298.4), "Ember Tune set card to Max: 123.0 MH/s at 302 W");
|
||||
let st = stock_tier(&curve_row(0, 100, 202.0, 18.9));
|
||||
assert_eq!((st.len(), st[0].id, st[0].source), (1, "max", "stock"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ 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 Tune tiers (8 October 2026): apply a tier (efficiency | balanced | max) to one card (key) or every card
|
||||
/// (None, which also sets the fleet tier) through the cap and lock path, no re-search
|
||||
TuneTier(Option<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>, Option<String>),
|
||||
/// the network step: the chosen network ("devnet-3" | "testnet-1") and whether the user confirmed what resets
|
||||
|
|
@ -194,7 +197,7 @@ 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, region: settings.region.clone(), currency: settings.currency.clone(), network: settings.network.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, prove_instead: settings.prove_instead };
|
||||
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(), tune_tier: settings.tune_tier.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, region: settings.region.clone(), currency: settings.currency.clone(), network: settings.network.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, prove_instead: settings.prove_instead };
|
||||
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();
|
||||
|
|
@ -1524,6 +1527,60 @@ impl Engine {
|
|||
}
|
||||
}
|
||||
}
|
||||
Cmd::TuneTier(key, tier) => {
|
||||
if key.is_none() {
|
||||
{
|
||||
let mut s = self.shared.settings.lock().unwrap();
|
||||
s.tune_tier = tier.clone();
|
||||
}
|
||||
self.st().settings.tune_tier = tier.clone();
|
||||
}
|
||||
let targets: Vec<usize> = {
|
||||
let st = self.st();
|
||||
st.mining.cards.iter().enumerate().filter(|(_, c)| key.as_ref().map(|k| &c.key == k).unwrap_or(true)).map(|(i, _)| i).collect()
|
||||
};
|
||||
let mut applied = Vec::new();
|
||||
for idx in targets {
|
||||
let (name, device, found) = {
|
||||
let st = self.st();
|
||||
let Some(c) = st.mining.cards.get(idx) else { continue };
|
||||
(c.name.clone(), c.device.clone(), crate::ember::tier_from_json(&c.tiers, &tier))
|
||||
};
|
||||
let Some((point, limit_w, mhs, w)) = found else {
|
||||
self.shared.event("warn", &format!("{name}: not measured yet: the search runs 2 min into steady mining"));
|
||||
continue;
|
||||
};
|
||||
let unix = crate::platform::unix_now();
|
||||
{
|
||||
let mut settings = self.shared.settings.lock().unwrap();
|
||||
let mut st = self.st();
|
||||
let Some(c) = st.mining.cards.get_mut(idx) else { continue };
|
||||
c.power_pct = point.power_pct;
|
||||
c.clock_cap_mhz = point.clock_mhz;
|
||||
c.mem_cap_mhz = point.mem_mhz;
|
||||
c.tier = tier.clone();
|
||||
c.tier_at = unix as f64;
|
||||
if c.vendor == "nvidia" && limit_w > 0.0 {
|
||||
c.power_limit_w = limit_w;
|
||||
c.power_applied = true;
|
||||
c.power_note = format!("power cap: {} W held by the {} tier", limit_w as u64, tier);
|
||||
}
|
||||
let e = settings.cards.entry(c.key.clone()).or_insert_with(|| crate::config::CardPref { enabled: c.enabled, identities: c.identities, ..Default::default() });
|
||||
e.tier = tier.clone();
|
||||
e.tier_at = unix;
|
||||
e.power_pct = point.power_pct;
|
||||
e.sweep_clock_mhz = point.clock_mhz;
|
||||
e.sweep_mem_mhz = point.mem_mhz;
|
||||
}
|
||||
self.tune_apply(idx, &device, &crate::ember::Step { point, watts: if limit_w > 0.0 { limit_w } else { w }, kind: crate::ember::Kind::Confirm });
|
||||
self.shared.event("ok", &format!("{name}: {} tier applied, {:.1} MH/s at {:.0} W ({})", tier, mhs, w, point_words(&point)));
|
||||
applied.push(name);
|
||||
}
|
||||
self.shared.save_settings();
|
||||
if applied.is_empty() {
|
||||
self.shared.event("warn", &format!("tier {tier}: no card has a measured set yet"));
|
||||
}
|
||||
}
|
||||
Cmd::TuneCardGoal(key, goal) => {
|
||||
{
|
||||
let mut s = self.shared.settings.lock().unwrap();
|
||||
|
|
@ -3628,6 +3685,23 @@ impl Engine {
|
|||
/// let the helper go. A confirm check that found a better neighbour queues the full plan.
|
||||
fn sweep_finish(&mut self, row: crate::ember::Row) {
|
||||
let Some(run) = self.sweep.take() else { return };
|
||||
// Ember Tune tiers: an automatic tune lands on the card's tier (its own, else the fleet's; balanced from install),
|
||||
// the tier's row of THIS search, instead of the goal's choice; the first time, the strip gets its one line
|
||||
let (row, tier_applied, first_tier) = {
|
||||
let settings = self.shared.settings.lock().unwrap();
|
||||
let st = self.st();
|
||||
match st.mining.cards.get(run.card) {
|
||||
Some(c) if !c.pinned && run.plan.kind != crate::ember::PlanKind::Baseline && c.vendor != "apple" && run.plan.limits.clock_max_mhz > 0 && c.tune_control => {
|
||||
let want = settings.cards.get(&c.key).map(|p| p.tier.clone()).filter(|t| !t.is_empty()).unwrap_or_else(|| settings.tune_tier.clone());
|
||||
let want = if crate::ember::TIER_IDS.contains(&want.as_str()) { want } else { "balanced".to_string() };
|
||||
match crate::ember::tier_row(&crate::ember::tiers(&run.rows), &want) {
|
||||
Some(r) => (r, Some(want), c.tier_at == 0.0),
|
||||
None => (row, None, false),
|
||||
}
|
||||
}
|
||||
_ => (row, None, false),
|
||||
}
|
||||
};
|
||||
let idx = run.card;
|
||||
let unix = crate::platform::unix_now();
|
||||
let kind = run.plan.kind;
|
||||
|
|
@ -3653,6 +3727,16 @@ impl Engine {
|
|||
c.tune_source = kind.name().into();
|
||||
c.tune_line = crate::ember::result_line(kind, row.mhs, row.watts, row.eff);
|
||||
c.tune_curve = run.rows.iter().map(|r| r.json()).collect();
|
||||
// Ember Tune tiers (8 October 2026): the three rows of this search; a card with no lever carries its stock row
|
||||
if kind == crate::ember::PlanKind::Baseline || c.vendor == "apple" || run.plan.limits.clock_max_mhz == 0 || !c.tune_control {
|
||||
c.tiers = crate::ember::stock_tier(&row).iter().map(|t| t.json()).collect();
|
||||
c.tier_note = if c.vendor == "apple" { "no lever: Apple silicon sets its own clocks and power".into() } else if !c.tune_control { "no lever: measure only until Power control is on".into() } else if run.plan.limits.clock_max_mhz == 0 { "no lever: no maximum clock reported".into() } else { "no lever: the search measured the stock point only".into() };
|
||||
c.tier = "max".into();
|
||||
} else {
|
||||
let t = crate::ember::tiers(&run.rows);
|
||||
c.tiers = t.iter().map(|x| x.json()).collect();
|
||||
c.tier_note = String::new();
|
||||
}
|
||||
// the core-clock knob's result (7 October 2026): the chosen lock against the cap point, and why it stopped
|
||||
if kind == crate::ember::PlanKind::Baseline {
|
||||
c.lock_note = if c.vendor == "apple" || run.plan.limits.clock_max_mhz == 0 { "no lever".into() } else { c.sweep_note.clone() };
|
||||
|
|
@ -3678,6 +3762,10 @@ impl Engine {
|
|||
c.power_pct = row.point.power_pct;
|
||||
c.clock_cap_mhz = row.point.clock_mhz;
|
||||
c.mem_cap_mhz = row.point.mem_mhz;
|
||||
if let Some(t) = &tier_applied {
|
||||
c.tier = t.clone();
|
||||
c.tier_at = unix as f64;
|
||||
}
|
||||
if c.vendor == "nvidia" {
|
||||
c.power_limit_w = row.limit;
|
||||
c.power_applied = true;
|
||||
|
|
@ -3693,6 +3781,10 @@ impl Engine {
|
|||
e.sweep_mhs = row.mhs;
|
||||
e.sweep_clock_mhz = row.point.clock_mhz;
|
||||
e.sweep_mem_mhz = row.point.mem_mhz;
|
||||
if let Some(t) = &tier_applied {
|
||||
e.tier = t.clone();
|
||||
e.tier_at = unix;
|
||||
}
|
||||
// Miner UI 4: the row's sub-line reads "(the ladder's floor)" when the chosen clock is the lowest step
|
||||
let floor_point = row.point.clock_mhz > 0 && row.point.clock_mhz <= run.plan.limits.clock_floor();
|
||||
e.sweep_floor = floor_point;
|
||||
|
|
@ -3728,6 +3820,15 @@ impl Engine {
|
|||
_ if pinned => self.shared.event("ok", &format!("{name}: best point {} ({line}); your setting stays pinned", point_words(&row.point))),
|
||||
_ => self.shared.event("ok", &format!("{name}: {line}, {} held", point_words(&row.point))),
|
||||
}
|
||||
if let Some(t) = &tier_applied {
|
||||
if !pinned {
|
||||
self.tune_apply(idx, &run.device, &crate::ember::Step { point: row.point, watts: row.limit, kind: crate::ember::Kind::Confirm });
|
||||
}
|
||||
if first_tier {
|
||||
let stock_w = run.rows.first().map(|r| r.watts).unwrap_or(0.0);
|
||||
self.shared.event("ok", &crate::ember::tier_set_line(&name, t, &row, stock_w));
|
||||
}
|
||||
}
|
||||
if pinned && kind != crate::ember::PlanKind::Baseline {
|
||||
let p = self.st().mining.cards.get(idx).map(|c| crate::ember::Point { clock_mhz: c.clock_cap_mhz, power_pct: c.power_pct, mem_mhz: c.mem_cap_mhz }).unwrap_or(run.before);
|
||||
let w = self.st().mining.cards.get(idx).map(requested_watts).unwrap_or(run.before_w);
|
||||
|
|
|
|||
|
|
@ -297,6 +297,29 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
shared.send(Cmd::ApplyCards(choices));
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
"/api/tune/tier" => {
|
||||
// Ember Tune tiers (8 October 2026): apply a measured tier to one card or every card, no re-search
|
||||
let tier = body.get("tier").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
if !crate::ember::TIER_IDS.contains(&tier.as_str()) {
|
||||
return Err("tier must be efficiency, balanced or max".into());
|
||||
}
|
||||
let key = body.get("key").and_then(|v| v.as_str()).filter(|k| !k.is_empty()).map(|k| k.to_string());
|
||||
let applied: Vec<String> = {
|
||||
let st = shared.state.lock().unwrap();
|
||||
match &key {
|
||||
Some(k) => {
|
||||
let Some(c) = st.mining.cards.iter().find(|c| &c.key == k) else { return Err("no such card".into()) };
|
||||
if crate::ember::tier_from_json(&c.tiers, &tier).is_none() {
|
||||
return Err("not measured yet: the search runs 2 min into steady mining".into());
|
||||
}
|
||||
vec![c.key.clone()]
|
||||
}
|
||||
None => st.mining.cards.iter().filter(|c| crate::ember::tier_from_json(&c.tiers, &tier).is_some()).map(|c| c.key.clone()).collect(),
|
||||
}
|
||||
};
|
||||
shared.send(Cmd::TuneTier(key, tier));
|
||||
Ok(json!({ "ok": true, "applied": applied }))
|
||||
}
|
||||
"/api/tune/goal" => {
|
||||
// Ember 2: the goal, the electricity price (pence per kWh) and the hill-climb switch
|
||||
let goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string());
|
||||
|
|
|
|||
|
|
@ -178,6 +178,12 @@ pub struct CardState {
|
|||
pub lock_steps: u32,
|
||||
pub lock_at: f64, // unix s the lock point was taken (0 = never)
|
||||
pub lock_note: String, // "rate fell 5.1 percent at 1200 MHz", "fingerprint mismatch at 1400 MHz, clocks reset", "the floor at 700 MHz", "no lever"
|
||||
// Ember Tune tiers (8 October 2026, src/ember.rs tiers; the UI lane's field shape): the three rows of the card's own
|
||||
// search (efficiency, balanced, max), the tier in force, why only stock exists, and when the tier was applied
|
||||
pub tiers: Vec<serde_json::Value>,
|
||||
pub tier: String, // "" | efficiency | balanced | max
|
||||
pub tier_note: String, // "" or "no lever: ..."
|
||||
pub tier_at: f64, // unix s (0 = never)
|
||||
// the kernel variant race (docs/design/miner-tuning.md): what the worker's last race chose
|
||||
pub variant: String,
|
||||
pub race_mhs: f64,
|
||||
|
|
@ -372,6 +378,8 @@ pub struct SettingsState {
|
|||
pub tune_goal: String,
|
||||
pub power_price_pence: f64,
|
||||
pub tune_climb: bool,
|
||||
/// Ember Tune tiers: the fleet tier (efficiency | balanced | max), balanced from install
|
||||
pub tune_tier: String,
|
||||
/// 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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue