Merge tiers-25 196e360e into release-0.3.25 (the three-tier Ember Tune, both halves: the tier buttons with rate, watts and the daily saving on the Cards strip, the Settings card and each panel; sweep on at Balanced from install)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-08 09:22:50 +00:00
commit 7c2edd81ff
5 changed files with 235 additions and 20 deletions

View file

@ -717,6 +717,23 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
.gl-details .line.lock.searching{color:var(--molten)}
.curve-svg text{font-family:var(--mono);font-size:9px;fill:var(--ash)}
.seg.small .seg-b{padding:4px 10px;font-size:var(--t-sm)}
/* tiers-25 (8 October 2026): the three tier buttons carry the row's rate and watts and the saving before the tap */
.tiers{display:inline-flex;gap:3px;background:var(--obsidian);border:1px solid var(--line);border-radius:12px;padding:3px;flex-wrap:wrap}
.tier-b{border:0;background:transparent;color:var(--ash);text-align:left;padding:7px 14px;border-radius:9px;cursor:pointer;display:flex;flex-direction:column;gap:2px;min-width:150px;transition:background .15s ease,color .15s ease}
.tier-b .n{font-weight:600;font-size:var(--t-base);color:var(--ink-2);line-height:1.2}
.tier-b .l{font-family:var(--mono);font-size:var(--t-sm);color:var(--bone);white-space:nowrap}
.tier-b .s{font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);white-space:nowrap}
.tier-b:hover .n{color:var(--bone)}
.tier-b.on{background:var(--graphite);box-shadow:0 1px 3px rgba(0,0,0,.25)}
.tier-b.on .n{color:var(--molten)}
.tiers.small .tier-b{min-width:128px;padding:5px 11px}
.gl-details .tiers-ctl{grid-template-columns:120px 1fr}
.gl-details .tiers-ctl .tier-row{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;min-width:0}
.gl-details .tiers-ctl .tier-row .btn{margin-left:auto}
.gl-details .tiers-ctl .tiers{justify-self:start}
.tiers.small .tier-b .s{font-size:10px}
.gl-details .tiers-ctl .help .mono{color:var(--ink-2)}
.tune-strip .line-text.dim{color:var(--ash)}
.gl-details .ctl .seg{justify-self:start}
@media (max-width:1180px){
.totals{grid-template-columns:1fr 1fr 1fr 1fr}

View file

@ -874,6 +874,81 @@ var View = (function () {
} else out.row = 'locked at ' + cd.lock_mhz + ' MHz' + (out.kneeMhz ? ', knee at ' + out.kneeMhz + ' MHz' : '');
return out;
}
// ---- the three tuning tiers (tiers-25, 8 October 2026; the project lead's definition): Ember tunes a card once (the search at first steady
// mining, weekly, or on Tune) and the tiers are rows of that search: efficiency = the best MH/W row outright, balanced = the
// best MH/W row within 1% of the top rate (the default, on from install), max = the top-rate row (stock when stock is the top).
// A tap applies the known row at once, no re-search. The engine's fields: card.tiers [{id, clock_mhz, power_pct, w, mhs, mhw,
// source}], card.tier (in force), card.tier_note (why only stock exists), card.tier_at; settings.tune_tier (the fleet's) ----
var TIERS = [{ id: 'efficiency', name: 'Efficiency', word: 'the best hashes per watt' }, { id: 'balanced', name: 'Balanced', word: 'the best hashes per watt within 1% of the top rate' }, { id: 'max', name: 'Maximum', word: 'the top rate' }];
var TIER_RULE = 'Ember never locks below the knee by itself. Going lower is your choice.';
var DEFAULT_PRICE_MINOR = 25; // 0.25 in the currency in force, until the user sets a price (main's order, 8 October 2026)
function tierOf(id) { id = String(id || ''); if (id === 'rate' || id === 'max_rate' || id === 'maximum') return 'max'; for (var i = 0; i < TIERS.length; i++) if (TIERS[i].id === id) return id; return 'balanced'; }
function tierName(id) { for (var i = 0; i < TIERS.length; i++) if (TIERS[i].id === id) return TIERS[i].name; return 'Balanced'; }
function priceOrDefault(pence) { return pence > 0 ? pence : DEFAULT_PRICE_MINOR; }
function priceFigure(pence) { var c = currencyNow(); return fmtMoney(priceOrDefault(pence) / 100, c.code, locale) + ' per kWh, ' + (pence > 0 ? 'set in Settings' : 'the default until you set yours in Settings'); }
function rateText(mhs) { return mhs >= 100 ? mhs.toFixed(1) : mhs.toFixed(1); }
function savingText(saveW, pence) { if (!(saveW > 0)) return 'stock'; var ppd = poundsPerDay(saveW, pence); return 'saves ' + Math.round(saveW) + ' W' + (pence > 0 && ppd !== null ? ', ' + money(ppd) + ' a day' : ''); }
// {kind: none|nolever|measuring|unmeasured|ready, note, inForce, lock, stock: {w, mhs}|null, tiers: [{id, name, word, line, sub, on, mhz, w, mhs, mhw, saveW, source}]}
function tierSet(cd, s, pence) {
cd = cd || {}; var st = (s && s.settings) || {};
var out = { kind: 'none', note: '', inForce: '', lock: '', stock: null, tiers: [] };
if (!tunable(cd) || !Array.isArray(cd.tiers)) return out;
var fleetTier = tierOf(st.tune_tier || st.tune_goal || 'balanced'), inForce = cd.tier ? tierOf(cd.tier) : '';
if (cd.tier_note) {
out.kind = 'nolever'; out.note = cap(String(cd.tier_note)); out.inForce = 'max';
var se = cd.tiers.filter(function (t) { return t && tierOf(t.id) === 'max'; })[0] || null;
var sw = se && se.w > 0 ? se.w : 0, sm = se && se.mhs > 0 ? se.mhs : 0, smw = se && se.mhw > 0 ? se.mhw : (sw > 0 && sm > 0 ? sm / sw : 0);
out.tiers = [{ id: 'max', name: 'Stock', word: 'as the card runs', line: sw > 0 && sm > 0 ? rateText(sm) + ' MH/s · ' + Math.round(sw) + ' W' : 'as it runs', sub: smw > 0 ? smw.toFixed(3) + ' MH/W' : '', on: true, mhz: 0, w: sw, mhs: sm, mhw: smw, saveW: 0, source: 'stock' }];
out.stock = sw > 0 ? { w: sw, mhs: sm } : null;
return out;
}
if (!cd.tiers.length) {
var measuring = cd.state === 'tuning' || cd.sweep_state === 'running';
out.kind = measuring ? 'measuring' : 'unmeasured';
out.note = measuring ? 'Measuring' + (cd.tune_steps > 0 ? ': step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : '') : (st.sweep === false ? 'Not measured yet: Ember Tune is off, press Tune' : 'Not measured yet: the search runs 2 min into steady mining');
out.tiers = TIERS.map(function (t) { return { id: t.id, name: t.name, word: t.word, line: measuring ? 'measuring' : 'not measured yet', sub: '', on: t.id === (inForce || fleetTier), mhz: 0, w: 0, mhs: 0, mhw: 0, saveW: 0, source: '' }; });
out.inForce = inForce || fleetTier;
return out;
}
var by = {}; cd.tiers.forEach(function (t) { if (t && t.id) by[tierOf(t.id)] = t; });
var stock = by.max || null; out.stock = stock ? { w: stock.w || 0, mhs: stock.mhs || 0 } : null;
out.kind = 'ready'; out.inForce = inForce || fleetTier;
out.tiers = TIERS.map(function (t) {
var r = by[t.id]; if (!r) return { id: t.id, name: t.name, word: t.word, line: 'not in this search', sub: '', on: t.id === out.inForce, mhz: 0, w: 0, mhs: 0, mhw: 0, saveW: 0, source: '' };
var w = r.w || 0, mhs = r.mhs || 0, mhw = r.mhw > 0 ? r.mhw : (w > 0 ? mhs / w : 0), saveW = stock && stock.w > w ? Math.round(stock.w - w) : 0;
return { id: t.id, name: t.name, word: t.word, line: rateText(mhs) + ' MH/s · ' + Math.round(w) + ' W', sub: mhw.toFixed(3) + ' MH/W · ' + savingText(saveW, pence), on: t.id === out.inForce, mhz: r.clock_mhz || 0, w: w, mhs: mhs, mhw: mhw, saveW: saveW, source: r.source || '' };
});
var capW = cd.lock_w > 0 ? cd.lock_w : (by[out.inForce] ? by[out.inForce].w : 0);
out.lock = (cd.lock_mhz > 0 ? 'locked at ' + cd.lock_mhz + ' MHz' : 'unlocked') + (capW > 0 ? ', ' + Math.round(capW) + ' W' : '') + (stock && stock.w > 0 ? '; stock ' + Math.round(stock.w) + ' W' : '');
return out;
}
// the strip: the measured cards summed per tier, the tier in force fleet-wide, how many cards are measured
// {inForce, measured, tunable, count, tiers: [{id, name, word, line, sub, on, w, mhs, saveW}]}
function fleetTiers(cards, s, pence) {
var st = (s && s.settings) || {}, inForce = tierOf(st.tune_tier || st.tune_goal || 'balanced');
var list = shownCards(cards).filter(tunable), sets = list.map(function (c) { return tierSet(c, s, pence); }), ready = sets.filter(function (t) { return t.kind === 'ready'; });
var measured = sets.filter(function (t) { return t.kind === 'ready' || (t.kind === 'nolever' && t.stock); }).length;
var out = { inForce: inForce, measured: measured, tunable: list.length, count: measured + ' of ' + list.length + ' card' + (list.length === 1 ? '' : 's') + ' measured', tiers: [] };
out.tiers = TIERS.map(function (t) {
var w = 0, mhs = 0, saveW = 0, n = 0;
ready.forEach(function (ts) { ts.tiers.forEach(function (x) { if (x.id === t.id && x.w > 0) { w += x.w; mhs += x.mhs; saveW += x.saveW; n++; } }); });
return { id: t.id, name: t.name, word: t.word, line: n ? rateText(mhs) + ' MH/s · ' + Math.round(w) + ' W' : (sets.some(function (x) { return x.kind === 'measuring'; }) ? 'measuring' : 'not measured yet'), sub: n ? savingText(saveW, pence) : '', on: t.id === inForce, w: w, mhs: mhs, saveW: saveW };
});
return out;
}
// the strip's one sentence: what Ember Tune is doing, did, or will do
function tierSentence(cards, s, now) {
var st = (s && s.settings) || {}, list = shownCards(cards).filter(tunable), tc = tuningCard(list);
if (st.tuning_off) return st.tuning_note || 'Tuning paused fleet-wide by the signed manifest.';
if (!list.length) return 'No card here can be tuned.';
if (tc) { var step = tc.tune_steps > 0 ? ': step ' + (tc.tune_step || 1) + ' of ' + tc.tune_steps : ''; return 'Measuring ' + tc.name + step + (tc.tune_eta_s > 0 ? ', mining again in ' + tuneEta(tc.tune_eta_s) : '') + '.'; }
if (st.sweep === false) return 'Ember Tune is off. The tiers still apply when you tap them; the automatic tune is one tap away in Settings.';
if (list.every(function (c) { return !!c.tier_note; })) return 'No lever on this machine: ' + String(list[0].tier_note).replace(/^no lever:\s*/i, '') + '. Ember measures and reports.';
var tier = tierOf(st.tune_tier || st.tune_goal || 'balanced'), last = null, n = 0;
list.forEach(function (c) { if (c.sweep_at > 0) { n++; if (!last || c.sweep_at > last.sweep_at) last = c; } });
if (last) return tierName(tier) + ' on ' + n + ' card' + (n === 1 ? '' : 's') + ', tuned ' + ago(now - last.sweep_at) + ' · ' + nextCheck(last.sweep_at, st.tune_period_s, now) + '.';
return 'Ember Tune is on at ' + tierName(tier) + '. The first tune runs 2 min into steady mining and sets each card’s ' + tierName(tier).toLowerCase() + ' point.';
}
// {kind: none|idle|measured|running|tuned|stopped|paused, fill: 0..1, label, sub, before, after, savingW, savingPct, savingText, next, points, lock}
function ember(cd, s, now) {
var tw = tuneWords(cd, s, now), out = { kind: tw.kind, fill: 0, label: '', sub: '', before: 0, after: 0, savingW: 0, savingPct: 0, savingText: '', next: '', points: [], lock: lockWords(cd) };
@ -1334,7 +1409,7 @@ var View = (function () {
}
function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; }
function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; }
return { NETWORKS: NETWORKS, networkWords: networkWords, networkAsk: networkAsk, networkIdOf: networkIdOf, ISO_CURRENCY: ISO_CURRENCY, EU_MEMBERS: EU_MEMBERS, CURRENCY_CODES: CURRENCY_CODES, minorOf: minorOf, priceFactor: priceFactor, currencyInfo: currencyInfo, regionFromLocale: regionFromLocale, currencyChoice: currencyChoice, currencyAsk: currencyAsk, applyMoney: applyMoney, fmtMoney: fmtMoney, setLocale: setLocale, setCurrency: setCurrency, currencyNow: currencyNow, unitWords: unitWords, VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, LOCK_RULE: LOCK_RULE, lockWords: lockWords, kneeWords: kneeWords, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords };
return { NETWORKS: NETWORKS, networkWords: networkWords, networkAsk: networkAsk, networkIdOf: networkIdOf, ISO_CURRENCY: ISO_CURRENCY, EU_MEMBERS: EU_MEMBERS, CURRENCY_CODES: CURRENCY_CODES, minorOf: minorOf, priceFactor: priceFactor, currencyInfo: currencyInfo, regionFromLocale: regionFromLocale, currencyChoice: currencyChoice, currencyAsk: currencyAsk, applyMoney: applyMoney, fmtMoney: fmtMoney, setLocale: setLocale, setCurrency: setCurrency, currencyNow: currencyNow, unitWords: unitWords, VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, driverWords: driverWords, vendorWord: vendorWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, LOCK_RULE: LOCK_RULE, lockWords: lockWords, kneeWords: kneeWords, TIERS: TIERS, TIER_RULE: TIER_RULE, DEFAULT_PRICE_MINOR: DEFAULT_PRICE_MINOR, tierOf: tierOf, tierName: tierName, priceOrDefault: priceOrDefault, priceFigure: priceFigure, tierSet: tierSet, fleetTiers: fleetTiers, tierSentence: tierSentence, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords };
})();
/* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ----------
One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned
@ -1660,6 +1735,7 @@ if (typeof document !== 'undefined') (function () {
else if (b.dataset.driverInstall !== undefined) api('api/drivers/install', { vendor: b.dataset.driverInstall }).then(function (r) { toast(r && r.ok === false ? (r.error || 'could not start the driver install') : 'Driver download started. Windows asks for permission once before the install; the workers on this vendor\u2019s cards stop for it and come back after.'); });
else if (b.dataset.driverRestart !== undefined) { if (window.confirm('Restart Windows now to finish the driver install? Mining stops until the app is back.')) api('api/drivers/restart', {}).then(function () { toast('Restarting in 20 seconds'); }); }
else if (b.dataset.price !== undefined) { showPage('settings'); $('s-price').focus(); }
else if (b.closest('[data-card-tier]') && b.dataset.tier) setTier(b.dataset.tier, key);
else if (b.closest('[data-card-goal]') && b.dataset.goal !== undefined) {
api('api/tune/goal', { key: key, goal: b.dataset.goal || '' }).then(function (r) { if (r && r.ok) toast(b.dataset.goal ? 'Goal for this card: ' + View.GOALS[b.dataset.goal] : 'Default: this card follows the goal set for all cards'); else toast('A goal for one card needs the next Ember Tune update'); }).catch(function () { toast('A per-card goal needs the Ember Tune update'); });
}
@ -1674,10 +1750,22 @@ if (typeof document !== 'undefined') (function () {
list.reduce(function (p, c) { return p.then(function () { return api('api/sweep/start', { key: c.key }).then(function (r) { if (!r || r.ok !== false) n++; }); }); }, Promise.resolve()).then(function () { toast(n === 1 ? 'Tune queued for 1 card' : 'Tune queued for ' + n + ' cards, one after another'); });
});
// the goal (ember-tune: POST api/tune/goal); a build without the route says so and keeps the old value
// tiers-25: a tap applies the known row at once (POST api/tune/tier); an engine before 0.3.25 has no route, so the goal route
// takes the tier as a goal and the toast says the numbers wait for the update
function setTier(tier, key) {
var body = key ? { key: key, tier: tier } : { tier: tier };
api('api/tune/tier', body).then(function (r) {
if (r && r.ok) { if (!key) store('igneum.goal', tier === 'max' ? 'rate' : tier); toast(View.tierName(tier) + (key ? ' applied to ' + ((cardOf(key) || {}).name || 'the card') : ' applied to ' + ((r.applied || []).length || 'every') + ' card' + ((r.applied || []).length === 1 ? '' : 's'))); }
else if (r && r.error && !/not found|unknown|404/i.test(r.error)) toast(r.error);
else api('api/tune/goal', key ? { key: key, goal: tier === 'max' ? 'rate' : tier } : { goal: tier === 'max' ? 'rate' : tier }).then(function (g) { toast(g && g.ok ? View.tierName(tier) + ' set as the goal; the one-tap tiers need the 0.3.25 engine' : 'The tiers need the 0.3.25 engine'); });
if (state) renderTuneCard(state);
}).catch(function () { toast('The engine did not answer'); });
}
function setGoal(goal) {
api('api/tune/goal', { goal: goal }).then(function (r) { if (r && r.ok) { store('igneum.goal', goal); toast('Goal: ' + View.GOALS[goal]); } else toast('The goal needs the next Ember Tune update'); if (state) renderTuneCard(state); }).catch(function () { toast('The goal needs the Ember Tune update'); });
}
['goal-seg', 'goal-seg-2'].forEach(function (id) { $(id).addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (b) setGoal(b.dataset.goal); }); });
['goal-seg', 'goal-seg-2'].forEach(function (id) { var el = $(id); if (el) el.addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (b) setGoal(b.dataset.goal); }); });
['tier-seg', 'tier-seg-2'].forEach(function (id) { var el = $(id); if (el) el.addEventListener('click', function (e) { var b = e.target.closest('.tier-b'); if (b && b.dataset.tier) setTier(b.dataset.tier); }); });
function powerControl(on) { api('api/power/control', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Power control on. Windows asks for administrator rights once' : 'Power control off. Nothing asks'); }); }
$('m-power-control').addEventListener('change', function () { powerControl(this.checked); });
$('s-power-control').addEventListener('change', function () { powerControl(this.checked); });
@ -2368,6 +2456,10 @@ if (typeof document !== 'undefined') (function () {
'<div class="acts">' + tuneBtn + sw + chev + '</div></div>' + drv + tune +
'<div class="gl-details" hidden>' + detailsHtml(cd, s) + '</div></div>';
}
// tiers-25: the three tier buttons (fleet or one card): the name, the row's rate and watts, the saving against stock
function tierButtonsHtml(tiers, label, cardKey) {
return (tiers || []).map(function (t) { return '<button class="tier-b' + (t.on ? ' on' : '') + '" data-tier="' + esc(t.id) + '" role="radio" aria-checked="' + (t.on ? 'true' : 'false') + '" title="' + esc(t.word) + '"><span class="n">' + esc(t.name) + '</span><span class="l">' + esc(t.line) + '</span>' + (t.sub ? '<span class="s">' + esc(t.sub) + '</span>' : '') + '</button>'; }).join('');
}
// the flame mark (the brand mark's two polygons) filled to a fraction: outline when not tuned, filling while tuning, solid when tuned
var flameN = 0;
function flameHtml(fill, kind) {
@ -2419,8 +2511,16 @@ if (typeof document !== 'undefined') (function () {
if (tl.length) h += '<div class="line">' + tl.join('<span class="sep"></span>') + '</div>';
var tm = TuneLine.model(cd, s.now), em = View.ember(cd, s, s.now);
if (View.tunable(cd)) {
var g = cd.tune_goal || '', goalSeg = '<div class="seg small" data-card-goal="1" role="radiogroup" aria-label="Goal for ' + esc(cd.name) + '">' + [['', 'Default', 'the goal set for all cards'], ['efficiency', 'Efficiency', 'most hashes per watt, a little rate traded'], ['balanced', 'Balanced', 'a little rate for most of the saving'], ['rate', 'Maximum', 'most hashes, watts second']].map(function (o) { return '<button class="seg-b' + (g === o[0] ? ' on' : '') + '" data-goal="' + o[0] + '" role="radio" aria-checked="' + (g === o[0] ? 'true' : 'false') + '" title="' + o[2] + '">' + o[1] + (o[0] === '' ? ' <span class="dim">· ' + esc(View.GOAL_NAMES[goalNow()]) + '</span>' : '') + '</button>'; }).join('') + '</div>';
h += '<div class="ctl"><span class="k">Goal</span>' + goalSeg + (em.kind === 'running' ? '<button class="btn small" data-stop="1">Stop</button>' : '<button class="btn small" data-tune="1">' + (em.kind === 'tuned' ? 'Retune' : 'Tune now') + '</button>') + '<p class="help">' + (cd.tune_goal === undefined ? 'A goal for one card needs the next Ember Tune update. Until then every card follows the goal above.' : 'This card’s own goal. Default follows the goal set for all cards. A new goal applies from the next tune.') + '</p></div>';
// tiers-25: the card's own three tiers with their numbers, the one in force, the lock in words, the note for a card
// without a lever or a measurement; a tap applies the known row at once
var ts = View.tierSet(cd, s, priceNow());
if (ts.kind !== 'none') {
h += '<div class="ctl tiers-ctl"><span class="k">Tier</span><div class="tier-row"><div class="tiers small" data-card-tier="1" role="radiogroup" aria-label="Tier for ' + esc(cd.name) + '">' + tierButtonsHtml(ts.tiers, 'Tier') + '</div>' +
(em.kind === 'running' ? '<button class="btn small" data-stop="1">Stop</button>' : '<button class="btn small" data-tune="1">' + (em.kind === 'tuned' ? 'Retune' : 'Tune now') + '</button>') + '</div>' +
'<p class="help">' + (ts.note ? esc(ts.note) + '. ' : '') + (ts.lock ? '<span class="mono">' + esc(ts.lock.charAt(0).toUpperCase() + ts.lock.slice(1)) + '.</span> ' : '') + (ts.kind === 'ready' ? 'A tap applies that row at once, no new search. ' + esc(View.TIER_RULE) : ts.kind === 'nolever' ? 'The card runs as the system sets it; Ember measures and reports.' : 'The tiers fill in from the card\u2019s own search.') + '</p></div>';
} else {
h += '<div class="ctl"><span class="k">Tune</span><span></span>' + (em.kind === 'running' ? '<button class="btn small" data-stop="1">Stop</button>' : '<button class="btn small" data-tune="1">' + (em.kind === 'tuned' ? 'Retune' : 'Tune now') + '</button>') + '<p class="help">The one-tap tiers need the 0.3.25 engine; this engine tunes to the goal set for all cards.</p></div>';
}
}
if (tm.kind === 'tuned' || tm.kind === 'measured') h += '<div class="line"><b>' + esc(tm.text) + '</b>' + (tm.note ? ' <span class="dim">' + esc(tm.note) + '</span>' : '') + (em.savingText ? ' <span class="save">' + esc(em.savingText) + '</span>' : '') + '</div>';
// the core-clock knob (knob-24): the lock against the unlocked row, the knee, the rule; nothing for a build without the fields
@ -2459,7 +2559,7 @@ if (typeof document !== 'undefined') (function () {
var tunables = presentCards.filter(function (c) { return View.tunable(c) && c.enabled; });
setHidden('btn-tune-all', !(tunables.length > 0) || !!(s.settings && s.settings.tuning_off) || !!View.tuningCard(presentCards));
var box = $('d-cards'), busy = box.querySelector('input[type=range]:active') || (box.contains(document.activeElement) && document.activeElement.tagName === 'INPUT');
var sig = JSON.stringify([rows, s.drivers, shown.map(function (c) { return [View.tuneWords(c, s, s.now), View.cells(c, price), c.driver_offer, c.identities, c.power_pct, c.pinned, c.power_applied, Math.round(c.temp_mem || 0), Math.round(c.gclk_mhz || 0), Math.round(c.mclk_mhz || 0), (c.tune_curve || []).length, c.tune_goal || '', c.sweep_watts, c.sweep_mhs, View.cardIgnDay(c, chain), c.lock_mhz, c.lock_at, c.lock_note, c.lock_step, c.lock_steps]; }), goalNow()]);
var sig = JSON.stringify([rows, s.drivers, shown.map(function (c) { return [View.tuneWords(c, s, s.now), View.cells(c, price), c.driver_offer, c.identities, c.power_pct, c.pinned, c.power_applied, Math.round(c.temp_mem || 0), Math.round(c.gclk_mhz || 0), Math.round(c.mclk_mhz || 0), (c.tune_curve || []).length, c.tune_goal || '', c.sweep_watts, c.sweep_mhs, View.cardIgnDay(c, chain), c.lock_mhz, c.lock_at, c.lock_note, c.lock_step, c.lock_steps, c.tiers, c.tier, c.tier_note, c.tier_at]; }), goalNow(), priceNow()]);
if (sig !== mineRowsSig && !busy) {
mineRowsSig = sig;
box.innerHTML = rows.length ? shown.map(function (cd, i) { return gpuLineHtml(cd, s, rows[i]); }).join('') : '<div class="empty' + (s.detecting ? '' : ' err') + '">' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No graphics card this app can use was found. The node runs on its own.') + '</div>';
@ -2483,9 +2583,14 @@ if (typeof document !== 'undefined') (function () {
function renderTuneCard(s) {
var st = s.settings || {}, goal = goalNow(), cards = shownCards(s.mining.cards), gw = View.goalWords(goal, cards, priceNow());
['goal-seg', 'goal-seg-2'].forEach(function (id) { document.querySelectorAll('#' + id + ' .seg-b').forEach(function (b) { var on = b.dataset.goal === goal; b.classList.toggle('on', on); b.setAttribute('aria-checked', on ? 'true' : 'false'); }); });
setText('goal-line', gw.consequence); setText('goal-line-2', gw.consequence);
var sched = View.schedule(s.mining.cards, s, s.now);
// tiers-25: the three tier buttons with the fleet's numbers, the sentence, the price figure, the rule
var ft = View.fleetTiers(s.mining.cards, s, priceNow()), ftHtml = tierButtonsHtml(ft.tiers, 'Tuning tier');
['tier-seg', 'tier-seg-2'].forEach(function (id) { var el = $(id); if (el && el.innerHTML !== ftHtml) el.innerHTML = ftHtml; });
setText('goal-line-2', ft.count + ' · ' + View.priceFigure(priceNow()));
var sched = View.tierSentence(s.mining.cards, s, s.now);
setText('tune-schedule', sched); setText('s-tune-schedule', sched);
setText('tier-price', 'Electricity ' + View.priceFigure(priceNow()) + ' · ' + ft.count + (ft.measured < ft.tunable && ft.tunable ? ' · a card measures 2 min into steady mining' : ''));
setText('tier-rule', View.TIER_RULE);
setText('fleet-saving', View.fleetSaving(s.mining.cards, s, s.now, priceNow()));
var hl = View.heatLine(s.heat);
setHidden('heat-line', !hl.text); setText('heat-line', hl.text); $('heat-line').className = 'line-text heat-line' + (hl.tone ? ' ' + hl.tone : '');
@ -2808,7 +2913,7 @@ if (typeof document !== 'undefined') (function () {
var c = s.mining.cards[0]; if (!c) return;
if (kind === 'running') { c.state = 'tuning'; c.sweep_state = 'running'; c.tune_step = 7; c.tune_steps = 9; c.tune_eta_s = 230; c.tune_plan = 'full'; c.sweep_note = 'tuning: holding 2472 MHz · 100% · 41 s (step 7 of 9)'; if (!(c.power_w > 0)) c.power_w = 219.6; if (!(c.hash_now > 0)) c.hash_now = 123.4; }
var curve = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 90, watts: 340, mhs: 122.4, eff: 0.36 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, { clock_mhz: 0, power_pct: 70, watts: 264, mhs: 118.1, eff: 0.447 }, { clock_mhz: 0, power_pct: 60, watts: 228, mhs: 104.9, eff: 0.46, mark: 'hot' }, { clock_mhz: 2780, power_pct: 80, watts: 298, mhs: 122.4, eff: 0.411 }, { clock_mhz: 2470, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }, { clock_mhz: 2160, power_pct: 80, watts: 281, mhs: 117.0, eff: 0.416 }, { clock_mhz: 1850, power_pct: 80, watts: 270, mhs: 109.2, eff: 0.404 }];
if (kind === 'tuned' || kind === 'mix') { c.tune_line = 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)'; c.tune_source = 'full'; c.tune_control = true; c.tune_clock_mhz = 2470; c.sweep_pct = 80; c.sweep_at = s.now - 7200; c.sweep_watts = 290; c.sweep_mhs = 122.3; c.tune_curve = curve; c.power_default_w = c.power_default_w || 374; c.lock_mhz = 2470; c.lock_mhs = 122.3; c.lock_w = 290; c.lock_mhw = 0.422; c.unlocked_mhs = 122.3; c.unlocked_w = 302; c.lock_step = 0; c.lock_steps = 0; c.lock_at = s.now - 7200; c.lock_note = 'rate fell 4.3 percent at 2160 MHz'; }
if (kind === 'tuned' || kind === 'mix') { c.tune_line = 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)'; c.tune_source = 'full'; c.tune_control = true; c.tune_clock_mhz = 2470; c.sweep_pct = 80; c.sweep_at = s.now - 7200; c.sweep_watts = 290; c.sweep_mhs = 122.3; c.tune_curve = curve; c.power_default_w = c.power_default_w || 374; c.lock_mhz = 2470; c.lock_mhs = 122.3; c.lock_w = 290; c.lock_mhw = 0.422; c.unlocked_mhs = 122.3; c.unlocked_w = 302; c.lock_step = 0; c.lock_steps = 0; c.lock_at = s.now - 7200; c.lock_note = 'rate fell 4.3 percent at 2160 MHz'; c.tiers = [{ id: 'efficiency', clock_mhz: 1850, power_pct: 80, w: 270, mhs: 109.2, mhw: 0.404, source: 'measured' }, { id: 'balanced', clock_mhz: 2470, power_pct: 80, w: 290, mhs: 122.3, mhw: 0.422, source: 'measured' }, { id: 'max', clock_mhz: 0, power_pct: 100, w: 374, mhs: 122.5, mhw: 0.328, source: 'stock' }]; c.tier = 'balanced'; c.tier_note = ''; c.tier_at = s.now - 7200; }
if (kind === 'running') { c.lock_step = 2; c.lock_steps = 4; }
if (kind === 'mix' && s.mining.cards[1]) { var d = s.mining.cards[1]; d.state = 'tuning'; d.sweep_state = 'running'; d.tune_step = 4; d.tune_steps = 9; d.tune_eta_s = 410; d.tune_plan = 'full'; d.tune_control = true; d.hash_now = d.hash_now || 98.1; }
if (kind === 'measured') { c.tune_line = 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W)'; c.tune_source = 'baseline'; c.tune_control = false; c.sweep_at = s.now - 600; c.sweep_note = 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed'; }

View file

@ -347,16 +347,15 @@
<div class="card tune-strip" id="tune-card">
<div class="card-head"><h3>Ember Tune</h3><span class="eyebrow">tunes every card for hashes per watt</span></div>
<div class="ts-row">
<div class="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button>
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false" title="a little rate for most of the saving">Balanced</button>
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false" title="most hashes, watts second">Maximum</button>
</div>
<span class="goal-line" id="goal-line"></span>
<!-- tiers-25 (8 October 2026): three tiers from each card's own search, the numbers on the button before the tap;
rendered by renderTuneCard from View.fleetTiers -->
<div class="tiers" id="tier-seg" role="radiogroup" aria-label="Tuning tier"></div>
<span class="ts-saving" id="fleet-saving"></span>
<button class="btn small" id="btn-tune-all" hidden>Tune all</button>
</div>
<p class="line-text" id="tune-schedule"></p>
<p class="line-text" id="tier-price"></p>
<p class="line-text dim" id="tier-rule"></p>
<p class="line-text heat-line" id="heat-line" hidden></p>
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
</div>
@ -486,14 +485,10 @@
<div class="card">
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="s-tune-eyebrow">Ember Tune</span></div>
<div class="goal-row">
<div class="seg" id="goal-seg-2" role="radiogroup" aria-label="Tuning goal">
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button>
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false" title="a little rate for most of the saving">Balanced</button>
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false" title="most hashes, watts second">Maximum</button>
</div>
<div class="tiers small" id="tier-seg-2" role="radiogroup" aria-label="Tuning tier"></div>
<span class="goal-line" id="goal-line-2"></span>
</div>
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Ember Tune</b> <span class="dim">Tunes every card for the most hashes per watt. Once after install, then every 7 days, and after a driver or program change.</span></span></label>
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Ember Tune</b> <span class="dim">Measures every card once (2 min into steady mining), then every 7 days and after a driver or program change, and applies the tier above. Off, the tiers still apply when you tap them.</span></span></label>
<label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once. Off, NVIDIA cards are only measured.</span><span class="dim" id="s-power-note"></span></span></label>
<label class="switch" id="s-climb-row" hidden><input type="checkbox" id="s-climb"><span class="track"></span><span class="sw-text"><b>Fine tuning</b> <span class="dim">Searches around the best point instead of stepping down a ladder.</span></span></label>
<p class="line-text" id="s-tune-schedule"></p>

View file

@ -1173,3 +1173,75 @@ test('the core-clock knob on the row and the panel: the lock, the knee, the sear
// the rule the panel states: the knob never sets a lock below the knee without the user's own choice
assert.equal(V.LOCK_RULE, 'Ember never locks the clock below the knee by itself. Going lower is your choice.');
});
// the three tuning tiers (tiers-25, 8 October 2026, main's order after the project lead's "these settings need to give the miner user everything
// they need"): each tier's rate, watts and MH/W before the tap from the card's own search (the engine's tiers[] in the agreed
// shape), the saving against stock in watts and money a day, the tier in force per card and fleet-wide, the lock in words, a card
// with no lever as "stock" alone with the reason, the first-run sentence. Known-failed first: V.tierSet is not a function on 0.3.24.
test('the tiers on a measured card: three buttons with their numbers, the saving against stock, the one in force, the lock words', () => {
const tiers = [{ id: 'efficiency', clock_mhz: 1200, power_pct: 80, w: 305, mhs: 121.9, mhw: 0.4, source: 'measured' }, { id: 'balanced', clock_mhz: 1950, power_pct: 90, w: 372, mhs: 126.8, mhw: 0.341, source: 'midpoint' }, { id: 'max', clock_mhz: 0, power_pct: 100, w: 450, mhs: 128.4, mhw: 0.285, source: 'stock' }];
const cd = card({ tiers, tier: 'balanced', tier_note: '', tier_at: NOW - 7200, lock_mhz: 1950, lock_w: 372, tune_control: true });
const t = V.tierSet(cd, settings(), 28);
assert.equal(t.kind, 'ready'); assert.equal(t.tiers.length, 3); assert.equal(t.inForce, 'balanced');
assert.deepEqual(t.tiers.map((x) => x.name), ['Efficiency', 'Balanced', 'Maximum']);
assert.equal(t.tiers[0].line, '121.9 MH/s · 305 W'); assert.equal(t.tiers[0].sub, '0.400 MH/W · saves 145 W, £0.97 a day');
assert.equal(t.tiers[1].on, true); assert.equal(t.tiers[1].line, '126.8 MH/s · 372 W'); assert.equal(t.tiers[1].sub, '0.341 MH/W · saves 78 W, £0.52 a day');
assert.equal(t.tiers[2].line, '128.4 MH/s · 450 W'); assert.equal(t.tiers[2].sub, '0.285 MH/W · stock');
assert.equal(t.tiers[0].saveW, 145); assert.equal(t.tiers[2].saveW, 0);
assert.equal(t.lock, 'locked at 1950 MHz, 372 W; stock 450 W');
assert.equal(t.note, '');
// no price: the saving reads in watts alone
assert.equal(V.tierSet(cd, settings(), 0).tiers[0].sub, '0.400 MH/W · saves 145 W');
// the lock words without a lock: the cap alone
assert.equal(V.tierSet(card({ tiers, tier: 'max', lock_mhz: 0, lock_w: 450 }), settings(), 28).lock, 'unlocked, 450 W; stock 450 W');
});
test('a card with no lever shows one tier, "stock", and says why; measuring and unmeasured cards say so', () => {
const nl = V.tierSet(card({ vendor: 'apple', tiers: [], tier: '', tier_note: 'no lever: Apple silicon sets its own clocks and power', tier_at: 0 }), settings(), 28);
assert.equal(nl.kind, 'nolever'); assert.equal(nl.tiers.length, 1); assert.equal(nl.tiers[0].name, 'Stock'); assert.equal(nl.tiers[0].on, true);
assert.equal(nl.note, 'No lever: Apple silicon sets its own clocks and power');
// the engine's one stock entry beside the note carries the numbers the button shows
const nl2 = V.tierSet(card({ vendor: 'apple', tiers: [{ id: 'max', clock_mhz: 0, power_pct: 100, w: 38, mhs: 26.7, mhw: 0.703, source: 'stock' }], tier: 'max', tier_note: 'no lever: Apple silicon sets its own clocks and power', tier_at: NOW - 60 }), settings(), 28);
assert.equal(nl2.kind, 'nolever'); assert.equal(nl2.tiers[0].line, '26.7 MH/s · 38 W'); assert.equal(nl2.tiers[0].sub, '0.703 MH/W');
const m = V.tierSet(card({ tiers: [], tier: '', tier_note: '', state: 'tuning', sweep_state: 'running', tune_step: 3, tune_steps: 9 }), settings(), 28);
assert.equal(m.kind, 'measuring'); assert.equal(m.note, 'Measuring: step 3 of 9'); assert.equal(m.tiers.length, 3); assert.equal(m.tiers[0].line, 'measuring');
const u = V.tierSet(card({ tiers: [], tier: '', tier_note: '' }), settings(), 28);
assert.equal(u.kind, 'unmeasured'); assert.equal(u.note, 'Not measured yet: the search runs 2 min into steady mining'); assert.equal(u.tiers[0].line, 'not measured yet');
assert.equal(V.tierSet(card({ tiers: [], tier: '', tier_note: '' }), settings({ sweep: false }), 28).note, 'Not measured yet: Ember Tune is off, press Tune');
// an engine before the fields: nothing is claimed
assert.equal(V.tierSet(card(), settings(), 28).kind, 'none');
assert.equal(V.tierSet(card({ vendor: 'other', kind: 'integrated', sweep_supported: false, tiers: [] }), settings(), 28).kind, 'none');
});
test('the fleet strip sums the measured cards per tier and names the tier in force; the price shows as a figure', () => {
const t5090 = [{ id: 'efficiency', clock_mhz: 1200, power_pct: 80, w: 305, mhs: 121.9, mhw: 0.4, source: 'measured' }, { id: 'balanced', clock_mhz: 1950, power_pct: 90, w: 372, mhs: 126.8, mhw: 0.341, source: 'midpoint' }, { id: 'max', clock_mhz: 0, power_pct: 100, w: 450, mhs: 128.4, mhw: 0.285, source: 'stock' }];
const t5080 = [{ id: 'efficiency', clock_mhz: 1100, power_pct: 80, w: 147, mhs: 71.2, mhw: 0.484, source: 'measured' }, { id: 'balanced', clock_mhz: 1800, power_pct: 90, w: 250, mhs: 76.1, mhw: 0.304, source: 'midpoint' }, { id: 'max', clock_mhz: 0, power_pct: 100, w: 360, mhs: 78.0, mhw: 0.217, source: 'stock' }];
const cards = [card({ tiers: t5090, tier: 'balanced', tier_note: '' }), card({ key: 'b', name: 'NVIDIA GeForce RTX 5080', tiers: t5080, tier: 'balanced', tier_note: '' }), card({ key: 'c', vendor: 'apple', tiers: [], tier: '', tier_note: 'no lever: Apple silicon sets its own clocks and power' })];
const f = V.fleetTiers(cards, settings({ tune_tier: 'balanced' }), 28);
assert.equal(f.inForce, 'balanced'); assert.equal(f.measured, 2); assert.equal(f.tunable, 3);
assert.equal(f.tiers[0].line, '193.1 MH/s · 452 W'); assert.equal(f.tiers[0].sub, 'saves 358 W, £2.41 a day');
assert.equal(f.tiers[1].line, '202.9 MH/s · 622 W'); assert.equal(f.tiers[1].on, true);
assert.equal(f.tiers[2].line, '206.4 MH/s · 810 W'); assert.equal(f.tiers[2].sub, 'stock');
assert.equal(f.count, '2 of 3 cards measured');
// the legacy goal name maps to the tier
assert.equal(V.fleetTiers(cards, settings({ tune_goal: 'rate' }), 28).inForce, 'max');
assert.equal(V.tierOf('rate'), 'max'); assert.equal(V.tierOf('efficiency'), 'efficiency'); assert.equal(V.tierOf('nonsense'), 'balanced');
// nothing measured yet: the buttons carry the words, not numbers
const g = V.fleetTiers([card({ tiers: [], tier: '', tier_note: '' })], settings(), 28);
assert.equal(g.measured, 0); assert.equal(g.tiers[0].line, 'not measured yet'); assert.equal(g.count, '0 of 1 card measured');
// the price figure: the set price, else the default of 0.25 in the currency in force, named as the default
V.setCurrency('GBP');
assert.equal(V.priceFigure(28), '£0.28 per kWh, set in Settings');
assert.equal(V.priceFigure(0), '£0.25 per kWh, the default until you set yours in Settings');
assert.equal(V.priceOrDefault(0), 25); assert.equal(V.priceOrDefault(28), 28);
V.setCurrency('');
});
test('the strip sentence: on at Balanced before the first tune, tuned with the next check, off with the tiers still one tap', () => {
const nv = (over) => card({ sweep_state: 'idle', sweep_note: '', sweep_at: 0, tune_line: '', tune_control: true, tiers: [], tier: '', tier_note: '', ...over });
assert.equal(V.tierSentence([nv()], settings({ sweep: true, tune_tier: 'balanced' }), NOW), 'Ember Tune is on at Balanced. The first tune runs 2 min into steady mining and sets each card’s balanced point.');
assert.match(V.tierSentence([nv({ sweep_at: NOW - 7200, tier: 'balanced', tier_at: NOW - 7200 })], settings({ tune_tier: 'balanced' }), NOW), /^Balanced on 1 card, tuned 2 h ago · next check /);
assert.equal(V.tierSentence([nv()], settings({ sweep: false }), NOW), 'Ember Tune is off. The tiers still apply when you tap them; the automatic tune is one tap away in Settings.');
assert.equal(V.tierSentence([nv({ state: 'tuning', sweep_state: 'running', tune_step: 2, tune_steps: 9, tune_eta_s: 500 })], settings(), NOW), 'Measuring NVIDIA GeForce RTX 5090: step 2 of 9, mining again in about 8 min.');
assert.equal(V.tierSentence([], settings(), NOW), 'No card here can be tuned.');
assert.equal(V.tierSentence([nv({ vendor: 'apple', tier_note: 'no lever: Apple silicon sets its own clocks and power', tiers: [{ id: 'max', w: 38, mhs: 26.7, mhw: 0.703, source: 'stock' }], sweep_at: NOW - 600 })], settings(), NOW), 'No lever on this machine: Apple silicon sets its own clocks and power. Ember measures and reports.');
assert.equal(V.fleetTiers([nv({ vendor: 'apple', tier_note: 'no lever: Apple silicon sets its own clocks and power', tiers: [{ id: 'max', w: 38, mhs: 26.7, mhw: 0.703, source: 'stock' }] })], settings(), 28).count, '1 of 1 card measured');
assert.equal(V.TIER_RULE, 'Ember never locks below the knee by itself. Going lower is your choice.');
});

View file

@ -29,6 +29,8 @@
// POST /api/card accepts the block card's PNG and answers a path.
// euro currency-21: region Germany, 38.69 c/kWh, every money line in euro (add &lang=de-DE for German placement)
// rig gpu-logos: one card per vendor (RTX 5090, RX 9070 XT, Arc B580, a UHD 770 iGPU off), every mark on one page
// tiers tiers-25: the 5090 and the 5080 with their three tiers measured, Balanced in force (tiers-first: the install's
// first minutes, nothing measured; tiers-mac: the Apple M5 Max with no lever, one stock entry)
// knob knob-24: the 5090 locked at 1,300 MHz with the knee at 1,200 on its curve, the 4070 in the clock search
// (knob-search: both cards searching)
// heat Ember Heat (mission item 7): a 5090 and a 4070 in heat mode holding 20 °C (heating, 60% duty), the
@ -261,6 +263,30 @@ function stateNow() {
s.mining.hash_total = 156.4; s.display_name = 'rig-01';
s.settings = { ...s.settings, sweep: true, power_control: true, tune_goal: 'balanced', power_price_pence: 26.32, region: 'gb' };
}
if (scenario === 'tiers' || scenario === 'tiers-first' || scenario === 'tiers-mac') {
// tiers-25 (8 October 2026): the three tiers from each card's own search (the project lead's definition: best MH/W row, best MH/W row within
// 1% of the top rate, the top-rate row). The rows are the shape of last night's 5090 and 5080 grids as main quoted them
// (5090 knee 1,200 MHz / 305 W, 5080 knee 1,100 MHz / 147 W), not a measurement. tiers-first: the install's first minutes,
// nothing measured yet, Ember Tune on at Balanced. tiers-mac: the recorded Apple M5 Max with no lever (one stock entry).
const first = scenario === 'tiers-first';
const t5090 = [{ id: 'efficiency', clock_mhz: 1200, power_pct: 80, w: 305, mhs: 121.9, mhw: 0.4, source: 'measured' }, { id: 'balanced', clock_mhz: 1950, power_pct: 90, w: 372, mhs: 126.8, mhw: 0.341, source: 'measured' }, { id: 'max', clock_mhz: 0, power_pct: 100, w: 450, mhs: 128.4, mhw: 0.285, source: 'stock' }];
const t5080 = [{ id: 'efficiency', clock_mhz: 1100, power_pct: 80, w: 147, mhs: 71.2, mhw: 0.484, source: 'measured' }, { id: 'balanced', clock_mhz: 1800, power_pct: 90, w: 250, mhs: 76.1, mhw: 0.304, source: 'measured' }, { id: 'max', clock_mhz: 0, power_pct: 100, w: 360, mhs: 78.0, mhw: 0.217, source: 'stock' }];
const curve5090 = [{ clock_mhz: 0, power_pct: 100, watts: 450, mhs: 128.4, eff: 0.285 }, { clock_mhz: 0, power_pct: 90, watts: 405, mhs: 128.1, eff: 0.316 }, { clock_mhz: 0, power_pct: 80, watts: 360, mhs: 127.2, eff: 0.353 }, { clock_mhz: 2472, power_pct: 90, watts: 398, mhs: 128.0, eff: 0.322 }, { clock_mhz: 1950, power_pct: 90, watts: 372, mhs: 126.8, eff: 0.341 }, { clock_mhz: 1545, power_pct: 80, watts: 332, mhs: 124.6, eff: 0.375 }, { clock_mhz: 1200, power_pct: 80, watts: 305, mhs: 121.9, eff: 0.4 }, { clock_mhz: 1100, power_pct: 80, watts: 298, mhs: 114.9, eff: 0.386 }];
if (scenario === 'tiers-mac') {
s.mining.cards = [{ ...recState.mining.cards[0], tiers: [{ id: 'max', clock_mhz: 0, power_pct: 100, w: 38, mhs: 26.7, mhw: 0.703, source: 'stock' }], tier: 'max', tier_note: 'no lever: Apple silicon sets its own clocks and power', tier_at: t - 600, tune_line: 'Measured: 26.7 MH/s at 38 W (0.703 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: t - 600, sweep_note: 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed', sweep_state: 'idle', lock_note: 'no lever', lock_at: 0, lock_mhz: 0 }];
s.settings = { ...s.settings, sweep: true, tune_tier: 'balanced', tune_goal: 'balanced' };
} else {
s.mining.cards = [{ ...recState.mining.cards[0], key: 'nvidia:0:NVIDIA GeForce RTX 5090', name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '21760 cores', vram_mb: 32768, device: '0', identities: 8, ids: ['8fafda27', '1b2c3d4e', '5f6a7b8c'], power_default_w: 450, power_pct: first ? 100 : 90, power_applied: true, power_limit_w: first ? 450 : 405, power_w: first ? 448 : 372, temp_gpu: first ? 66 : 59, temp_mem: first ? 78 : 68, gclk_mhz: first ? 2790 : 1950, clock_cap_mhz: first ? 0 : 1950, telemetry_at: t, hash_now: first ? 128.3 : 126.8, hash_avg: first ? 128.1 : 126.5, accepted: first ? 0 : 102, state: 'mining', sweep_supported: true, tune_control: true, sweep_pct: first ? 0 : 90,
...(first ? { sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0, tiers: [], tier: '', tier_note: '', tier_at: 0, lock_at: 0, lock_mhz: 0, lock_note: '' }
: { sweep_state: 'idle', sweep_note: '', tune_line: 'Tuned: 126.8 MH/s at 372 W (0.341 MH/W)', tune_source: 'full', tune_clock_mhz: 1950, sweep_at: t - 7200, sweep_watts: 372, sweep_mhs: 126.8, sweep_eff: 0.341, tune_before_watts: 450, tune_before_mhs: 128.4, tune_curve: curve5090, tiers: t5090, tier: 'balanced', tier_note: '', tier_at: t - 7200, lock_mhz: 1950, lock_mhs: 126.8, lock_w: 372, lock_mhw: 0.341, unlocked_mhs: 128.1, unlocked_w: 405, lock_at: t - 7200, lock_note: 'rate fell 5.8 percent at 1100 MHz' }) },
{ ...recState.mining.cards[0], key: 'nvidia:1:NVIDIA GeForce RTX 5080', name: 'NVIDIA GeForce RTX 5080', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '10752 cores', vram_mb: 16384, device: '1', identities: 8, ids: ['9a8b7c6d', '0c1d2e3f'], power_default_w: 360, power_pct: first ? 100 : 90, power_applied: true, power_limit_w: first ? 360 : 324, power_w: first ? 358 : 250, temp_gpu: first ? 64 : 57, temp_mem: 0, gclk_mhz: first ? 2700 : 1800, clock_cap_mhz: first ? 0 : 1800, telemetry_at: t, hash_now: first ? 77.9 : 76.1, hash_avg: first ? 77.8 : 76.0, accepted: first ? 0 : 61, state: 'mining', sweep_supported: true, tune_control: true, sweep_pct: first ? 0 : 90,
...(first ? { sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0, tiers: [], tier: '', tier_note: '', tier_at: 0, lock_at: 0, lock_mhz: 0, lock_note: '' }
: { sweep_state: 'idle', sweep_note: '', tune_line: 'Tuned: 76.1 MH/s at 250 W (0.304 MH/W)', tune_source: 'full', tune_clock_mhz: 1800, sweep_at: t - 6900, sweep_watts: 250, sweep_mhs: 76.1, sweep_eff: 0.304, tune_before_watts: 360, tune_before_mhs: 78.0, tiers: t5080, tier: 'balanced', tier_note: '', tier_at: t - 6900, lock_mhz: 1800, lock_mhs: 76.1, lock_w: 250, lock_mhw: 0.304, unlocked_mhs: 77.8, unlocked_w: 324, lock_at: t - 6900, lock_note: 'rate fell 6.1 percent at 1000 MHz' }) }];
s.mining.hash_total = first ? 206.2 : 202.9; s.mining.state = 'mining'; s.display_name = 'study-pc'; s.uptime_s = first ? 95 : recState.uptime_s;
s.settings = { ...s.settings, sweep: true, power_control: true, tune_tier: 'balanced', tune_goal: 'balanced', power_price_pence: first ? 0 : 28, region: 'gb' };
if (!first) s.events = [{ kind: 'info', t: t - 6900, text: 'Ember Tune set NVIDIA GeForce RTX 5080 to Balanced: 76.1 MH/s at 250 W, 110 W under stock' }, { kind: 'info', t: t - 7200, text: 'Ember Tune set NVIDIA GeForce RTX 5090 to Balanced: 126.8 MH/s at 372 W, 78 W under stock' }].concat(s.events).slice(0, 40);
}
}
if (scenario === 'knob' || scenario === 'knob-search') {
// the core-clock knob (knob-24, 7 October 2026): the 5090 locked at 1,300 MHz after the ladder stopped at the knee
// (1,200 MHz, the rate 5.2% under the cap row), read against the unlocked row at the same 80% cap; the 4070 in the