diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 82434f427..42d2b67e2 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -710,6 +710,11 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)} .curve-svg circle{fill:var(--ember)} .curve-svg circle.chosen{fill:none;stroke:var(--ember);stroke-width:2} .curve-svg circle.marked{fill:none;stroke:var(--ash);stroke-width:1.2} +/* the knee (knob-24): the first clock step whose rate fell past the tolerance; dashed, so it reads apart from the chosen ring and a rejected point */ +.curve-svg circle.knee{fill:none;stroke:var(--molten);stroke-width:1.4;stroke-dasharray:2 2} +.gl-details .line.lock b{color:var(--bone)} +.gl-details .line.lock.unlocked b,.gl-details .line.lock.nolever b{color:var(--ink-2);font-weight:400} +.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)} .gl-details .ctl .seg{justify-self:start} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 9fc61652d..b519382a5 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -837,23 +837,61 @@ var View = (function () { if (cd.tune_source === 'full' && r && r.watts > 0 && (r.power_pct || 0) >= 100 && !(r.clock_mhz > 0)) return { watts: r.watts, mhs: r.mhs || 0 }; return null; } - // {kind: none|idle|measured|running|tuned|stopped|paused, fill: 0..1, label, sub, before, after, savingW, savingPct, savingText, next, points} + // ---- the core-clock knob's display (knob-24, 7 October 2026; src/ember.rs lock_result, state.rs lock_*): the lock + // against the unlocked row at the same cap, the knee in words, the search step, no lever. Display only: the + // knob is the engine's; the panel states the rule and never offers a lock below the knee ---- + var LOCK_RULE = 'Ember never locks the clock below the knee by itself. Going lower is your choice.'; + function mhzOf(note) { var m = /at (\d+) MHz/.exec(note || ''); return m ? parseInt(m[1], 10) : 0; } + // the stop reason in the panel's words: the knee (rate fell), a faulted row (clocks reset), the floor (no knee), or the bare stop + function kneeWords(note) { + note = String(note || ''); var m; + if ((m = /^rate fell ([\d.]+) percent at (\d+) MHz/.exec(note))) return 'the knee at ' + m[2] + ' MHz, where the rate fell ' + m[1] + '%'; + if ((m = /^fingerprint mismatch at (\d+) MHz/.exec(note))) return 'stopped at ' + m[1] + ' MHz on a hash mismatch, clocks reset to the last good step'; + if ((m = /^the floor at (\d+) MHz/.exec(note))) return 'no knee down to the floor at ' + m[1] + ' MHz'; + return note; + } + // {kind: none|nolever|searching|locked|unlocked, text, against, knee, row, savingW, ratePct, kneeMhz, mhz} + function lockWords(cd) { + cd = cd || {}; var out = { kind: 'none', text: '', against: '', knee: '', row: '', savingW: 0, ratePct: 0, kneeMhz: 0, mhz: 0 }; + if (cd.lock_note === 'no lever') { out.kind = 'nolever'; out.text = 'No clock lock on this card: the system sets its clocks'; return out; } + if (cd.lock_steps > 0) { out.kind = 'searching'; out.text = 'Locking clocks: step ' + (cd.lock_step || 1) + ' of ' + cd.lock_steps; out.row = 'locking clocks: step ' + (cd.lock_step || 1) + ' of ' + cd.lock_steps; return out; } + if (!(cd.lock_at > 0)) return out; + var note = String(cd.lock_note || ''), fell = /^rate fell ([\d.]+) percent at (\d+) MHz/.exec(note); + out.kneeMhz = fell ? parseInt(fell[2], 10) : 0; out.knee = kneeWords(note); + if (!(cd.lock_mhz > 0)) { + out.kind = 'unlocked'; + out.text = 'Clock left unlocked' + (fell ? ': the first lock step fell ' + fell[1] + '% at ' + fell[2] + ' MHz' : note ? ': ' + kneeWords(note) : ''); + out.row = 'clock unlocked' + (fell ? ', the knee came at ' + fell[2] + ' MHz' : ''); + return out; + } + out.kind = 'locked'; out.mhz = cd.lock_mhz; + out.text = 'Locked at ' + cd.lock_mhz + ' MHz: ' + (cd.lock_mhs || 0).toFixed(1) + ' MH/s at ' + Math.round(cd.lock_w || 0) + ' W (' + (cd.lock_mhw || 0).toFixed(3) + ' MH/W)'; + if (cd.unlocked_w > 0) { + out.against = 'unlocked at the same cap: ' + (cd.unlocked_mhs || 0).toFixed(1) + ' MH/s at ' + Math.round(cd.unlocked_w) + ' W'; + out.savingW = Math.max(0, Math.round(cd.unlocked_w - (cd.lock_w || 0))); + out.ratePct = cd.unlocked_mhs > 0 ? Math.max(0, (cd.unlocked_mhs - (cd.lock_mhs || 0)) / cd.unlocked_mhs * 100) : 0; + out.row = 'the lock: ' + out.savingW + ' W less' + (out.ratePct < 0.05 ? ', no rate lost' : ' for ' + out.ratePct.toFixed(1) + '% of rate') + (out.kneeMhz ? ', knee at ' + out.kneeMhz + ' MHz' : ''); + } else out.row = 'locked at ' + cd.lock_mhz + ' MHz' + (out.kneeMhz ? ', knee at ' + out.kneeMhz + ' MHz' : ''); + return out; + } + // {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: [] }; + 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) }; if (tw.kind === 'none') return out; var st = (s && s.settings) || {}; out.next = nextCheck(cd.sweep_at, st.tune_period_s, now).replace(/^next check /, ''); - out.points = (cd.tune_curve || []).filter(function (r) { return r.watts > 0 && r.mhs > 0; }).map(function (r) { return { w: r.watts, mhs: r.mhs, eff: r.eff || (r.mhs / r.watts), marked: !!r.mark, chosen: cd.sweep_watts > 0 && Math.abs(r.watts - cd.sweep_watts) < 0.5 && Math.abs((r.mhs || 0) - (cd.sweep_mhs || 0)) < 0.05 }; }); + var kneeMhz = out.lock.kneeMhz; + out.points = (cd.tune_curve || []).filter(function (r) { return r.watts > 0 && r.mhs > 0; }).map(function (r) { return { w: r.watts, mhs: r.mhs, eff: r.eff || (r.mhs / r.watts), marked: !!r.mark, chosen: cd.sweep_watts > 0 && Math.abs(r.watts - cd.sweep_watts) < 0.5 && Math.abs((r.mhs || 0) - (cd.sweep_mhs || 0)) < 0.05, knee: kneeMhz > 0 && r.clock_mhz === kneeMhz }; }); if (cd.tune_floor) out.floor = true; out.heat = heatRowWords(s && s.heat); - if (tw.kind === 'running') { out.fill = tw.progress >= 0 ? tw.progress : 0.1; out.label = cd.tune_steps > 0 ? 'Tuning: step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : 'Tuning'; out.sub = cd.tune_eta_s > 0 ? tuneEta(cd.tune_eta_s) + ' left' : ''; return out; } + if (tw.kind === 'running') { out.fill = tw.progress >= 0 ? tw.progress : 0.1; out.label = cd.tune_steps > 0 ? 'Tuning: step ' + (cd.tune_step || 1) + ' of ' + cd.tune_steps : 'Tuning'; out.sub = [out.lock.kind === 'searching' ? out.lock.row : '', cd.tune_eta_s > 0 ? tuneEta(cd.tune_eta_s) + ' left' : ''].filter(Boolean).join(' · '); return out; } if (tw.kind === 'paused') { out.label = 'Tuning paused by Igneum'; out.sub = ''; return out; } if (tw.kind === 'stopped') { out.label = tw.text; return out; } if (tw.kind === 'measured') { out.fill = 0.5; out.label = 'Measured, not tuned'; out.sub = /Power control/i.test(cd.sweep_note || '') ? 'Power control is off' : cd.vendor === 'apple' ? 'Apple silicon sets its own power' : (tw.note || '').replace(/^Measured only /, ''); return out; } if (tw.kind === 'tuned') { out.fill = 1; var when = cd.sweep_at > 0 && now ? ago(now - cd.sweep_at) : ''; out.label = 'Tuned' + (when ? ' ' + when : '') + (cd.tune_source === 'confirm' ? ' from what other miners found' : ''); - out.sub = tunePoint(cd) + (cd.tune_floor ? ' (the lowest clock Ember will set)' : '') + (out.next ? ' · next check ' + out.next : ''); + out.sub = tunePoint(cd) + (cd.tune_floor ? ' (the lowest clock Ember will set)' : '') + (out.lock.row ? ' · ' + out.lock.row : '') + (out.next ? ' · next check ' + out.next : ''); var b = beforeOf(cd); out.after = cd.sweep_watts > 0 ? cd.sweep_watts : 0; out.before = b ? b.watts : 0; if (out.before > 0 && out.after > 0 && out.before > out.after) { out.savingW = Math.round(out.before - out.after); @@ -1296,7 +1334,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, 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, 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 @@ -2354,10 +2392,11 @@ if (typeof document !== 'undefined') (function () { var X = function (w) { return L + (w1 > w0 ? (w - w0) / (w1 - w0) : 0.5) * (W - L - 8); }, Y = function (m) { return 6 + (m1 > m0 ? 1 - (m - m0) / (m1 - m0) : 0.5) * (H - B - 6); }; var sorted = points.slice().sort(function (a, b) { return a.w - b.w; }); var path = sorted.map(function (p, i) { return (i ? 'L' : 'M') + X(p.w).toFixed(1) + ' ' + Y(p.mhs).toFixed(1); }).join(' '); - var dots = sorted.map(function (p) { return ''; }).join(''); + var dots = sorted.map(function (p) { return ''; }).join(''); + var knee = points.some(function (p) { return p.knee; }); return '
' + dots + '' + Math.round(w0) + ' W' + Math.round(w1) + ' W' + - '' + m0.toFixed(0) + '' + m1.toFixed(0) + 'Hash rate against power for every step of the last tune. The ringed point is the one Ember kept. A hollow point was rejected: a fault, a hot card or a slowed memory clock.
'; + '' + m0.toFixed(0) + '' + m1.toFixed(0) + 'Hash rate against power for every step of the last tune. The ringed point is the one Ember kept. A hollow point was rejected: a fault, a hot card or a slowed memory clock.' + (knee ? ' The dashed point is the knee: the first clock step whose rate fell past the tolerance, so the lock stays above it.' : '') + ''; } // the card's details (one chevron): the cap slider, identities, pin, the telemetry, the facts, the last tune's table function detailsHtml(cd, s) { @@ -2384,6 +2423,12 @@ if (typeof document !== 'undefined') (function () { h += '
Goal' + goalSeg + (em.kind === 'running' ? '' : '') + '

' + (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.') + '

'; } if (tm.kind === 'tuned' || tm.kind === 'measured') h += '
' + esc(tm.text) + '' + (tm.note ? ' ' + esc(tm.note) + '' : '') + (em.savingText ? ' ' + esc(em.savingText) + '' : '') + '
'; + // the core-clock knob (knob-24): the lock against the unlocked row, the knee, the rule; nothing for a build without the fields + var lk = em.lock || View.lockWords(cd); + if (lk.kind !== 'none') { + h += '
' + esc(lk.text) + '' + (lk.against ? '' + esc(lk.against) + '' : '') + (lk.knee && lk.kind !== 'unlocked' ? '' + esc(lk.knee) + '' : '') + (lk.savingW > 0 ? '' + esc(lk.savingW + ' W less' + (lk.ratePct < 0.05 ? ', no rate lost' : ' for ' + lk.ratePct.toFixed(1) + '% of rate')) + '' : '') + '
'; + if (lk.kind === 'locked' || lk.kind === 'unlocked') h += '

' + esc(View.LOCK_RULE) + ' The lock is read against the unlocked row at the same power cap. A step that fell past the tolerance, or faulted, ends the search.

'; + } h += curveHtml(em.points); h += '
Miners on this card

How many miners this card runs as. Each one votes and is paid on its own. 8 suits a big card, 2 a small one, 1 a built-in GPU. Changing it restarts the card.

'; var facts = [cd.worker ? 'runs on ' + cd.worker : '', cd.detail || '', cardTitle(cd), cd.driver_os ? 'driver ' + cd.driver_os : cd.driver ? 'driver ' + cd.driver : ''].filter(Boolean); @@ -2414,7 +2459,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)]; }), 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]; }), goalNow()]); if (sig !== mineRowsSig && !busy) { mineRowsSig = sig; box.innerHTML = rows.length ? shown.map(function (cd, i) { return gpuLineHtml(cd, s, rows[i]); }).join('') : '
' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No graphics card this app can use was found. The node runs on its own.') + '
'; @@ -2763,7 +2808,8 @@ 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; } + 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 === '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'; } } diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs index ab81ec0f5..19a466207 100644 --- a/app/igneum-app/ui/view.test.mjs +++ b/app/igneum-app/ui/view.test.mjs @@ -1126,3 +1126,50 @@ test('the dashboard cap scales with the window above 1080p and Settings gains a assert.match(block, /#page-settings\{display:grid;grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/, 'Settings two columns at the wide cap'); assert.match(block, /#page-settings>\.card\{max-width:640px/, 'a Settings card never past its design width'); }); + +// the core-clock knob's display (knob-24, 7 October 2026; the engine's lock_* fields from src/ember.rs lock_result): +// the lock against the unlocked point at the same cap, the knee, the search step while the clock ladder runs, no lever +test('the core-clock knob on the row and the panel: the lock, the knee, the search step, no lever', () => { + const curve = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, + { clock_mhz: 2472, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }, { clock_mhz: 1854, power_pct: 80, watts: 227, mhs: 121.6, eff: 0.536 }, { clock_mhz: 1545, power_pct: 80, watts: 206, mhs: 121.1, eff: 0.588 }, + { clock_mhz: 1300, power_pct: 80, watts: 190, mhs: 120.7, eff: 0.635 }, { clock_mhz: 1200, power_pct: 80, watts: 182, mhs: 116.0, eff: 0.637 }]; + const locked = card({ tune_line: 'Tuned: 120.7 MH/s at 190 W (0.635 MH/W)', tune_source: 'full', tune_control: true, tune_clock_mhz: 1300, sweep_pct: 80, sweep_at: NOW - 7200, sweep_watts: 190, sweep_mhs: 120.7, tune_curve: curve, sweep_state: 'idle', sweep_note: '', + lock_mhz: 1300, lock_mhs: 120.7, lock_w: 190, lock_mhw: 0.635, unlocked_mhs: 122.3, unlocked_w: 302, lock_step: 0, lock_steps: 0, lock_at: NOW - 7200, lock_note: 'rate fell 5.2 percent at 1200 MHz' }); + const lw = V.lockWords(locked); + assert.equal(lw.kind, 'locked'); + assert.equal(lw.text, 'Locked at 1300 MHz: 120.7 MH/s at 190 W (0.635 MH/W)'); + assert.equal(lw.against, 'unlocked at the same cap: 122.3 MH/s at 302 W'); + assert.equal(lw.knee, 'the knee at 1200 MHz, where the rate fell 5.2%'); + assert.equal(lw.row, 'the lock: 112 W less for 1.3% of rate, knee at 1200 MHz'); + assert.equal(lw.savingW, 112); assert.equal(lw.ratePct.toFixed(1), '1.3'); assert.equal(lw.kneeMhz, 1200); + const e = V.ember(locked, settings(), NOW); + assert.equal(e.lock.kind, 'locked'); + assert.match(e.sub, /^1300 MHz at 80% · the lock: 112 W less for 1\.3% of rate, knee at 1200 MHz · next check /); + assert.deepEqual(e.points.filter((p) => p.knee).map((p) => p.w), [182], 'the knee row is flagged on the curve'); + assert.equal(e.points.filter((p) => p.chosen).length, 1); + // a lock that lost no rate says so + assert.equal(V.lockWords({ ...locked, lock_mhs: 122.4 }).row, 'the lock: 112 W less, no rate lost, knee at 1200 MHz'); + // the search ended on a faulted row: the clocks were reset to the last good step + assert.equal(V.lockWords({ ...locked, lock_mhz: 2472, lock_note: 'fingerprint mismatch at 2163 MHz, clocks reset' }).knee, 'stopped at 2163 MHz on a hash mismatch, clocks reset to the last good step'); + assert.equal(V.lockWords({ ...locked, lock_note: 'the floor at 700 MHz' }).knee, 'no knee down to the floor at 700 MHz'); + assert.equal(V.lockWords({ ...locked, lock_note: 'stopped at 1400 MHz' }).knee, 'stopped at 1400 MHz'); + assert.equal(V.ember({ ...locked, lock_note: 'the floor at 700 MHz' }, settings(), NOW).points.filter((p) => p.knee).length, 0); + // the first lock step was already past the knee: the clock stays unlocked and the row says why + const un = V.lockWords({ ...locked, lock_mhz: 0, tune_clock_mhz: 0, lock_mhs: 122.3, lock_w: 302, lock_mhw: 0.405, lock_note: 'rate fell 3.0 percent at 2472 MHz' }); + assert.equal(un.kind, 'unlocked'); + assert.equal(un.text, 'Clock left unlocked: the first lock step fell 3.0% at 2472 MHz'); + assert.equal(un.row, 'clock unlocked, the knee came at 2472 MHz'); + // while the clock ladder runs: the row's sub names the search step inside the tune's step + const searching = V.ember(card({ state: 'tuning', sweep_state: 'running', tune_step: 9, tune_steps: 14, tune_eta_s: 410, lock_step: 4, lock_steps: 9 }), settings(), NOW); + assert.equal(searching.label, 'Tuning: step 9 of 14'); assert.equal(searching.sub, 'locking clocks: step 4 of 9 · about 7 min left'); + assert.equal(searching.lock.kind, 'searching'); assert.equal(searching.lock.text, 'Locking clocks: step 4 of 9'); + // no lever (Apple silicon, or a card without a maximum clock): the panel says so, the row adds nothing + const nl = V.lockWords(card({ vendor: 'apple', lock_note: 'no lever', lock_at: 0, lock_mhz: 0 })); + assert.equal(nl.kind, 'nolever'); assert.equal(nl.text, 'No clock lock on this card: the system sets its clocks'); assert.equal(nl.row, ''); + // a build without the fields, or a card not yet tuned: nothing is claimed + assert.equal(V.lockWords(card()).kind, 'none'); + assert.equal(V.lockWords(card({ lock_at: 0, lock_note: '' })).kind, 'none'); + assert.equal(V.ember(card({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'full', tune_control: true, tune_clock_mhz: 2470, sweep_pct: 80, sweep_at: NOW - 7200, sweep_state: 'idle', sweep_note: '' }), settings(), NOW).sub.indexOf('the lock'), -1); + // 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.'); +}); diff --git a/tools/ui-mock/server.mjs b/tools/ui-mock/server.mjs index 36602fd2c..87deb5a82 100644 --- a/tools/ui-mock/server.mjs +++ b/tools/ui-mock/server.mjs @@ -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 +// 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 // region United Kingdom at the Ofgem price; ?page=cards|earnings|settings for the three lines // The log grows by a few lines a second in every scenario, so follow and scroll-to-bottom can be exercised. @@ -259,6 +261,21 @@ 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 === '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 + // clock search (knob-search: both cards searching). The rows are the shape of the PC 1 passes, not a measurement. + const search = scenario === 'knob-search'; + const ladder = [{ 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: 2472, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }, { clock_mhz: 1854, power_pct: 80, watts: 227, mhs: 121.6, eff: 0.536 }, { clock_mhz: 1545, power_pct: 80, watts: 206, mhs: 121.1, eff: 0.588 }, { clock_mhz: 1300, power_pct: 80, watts: 190, mhs: 120.7, eff: 0.635 }, { clock_mhz: 1200, power_pct: 80, watts: 182, mhs: 116.0, eff: 0.637 }]; + const locked = { lock_mhz: 1300, lock_mhs: 120.7, lock_w: 190, lock_mhw: 0.635, unlocked_mhs: 122.3, unlocked_w: 302, lock_step: 0, lock_steps: 0, lock_at: t - 7200, lock_note: 'rate fell 5.2 percent at 1200 MHz' }; + const searching = { state: 'tuning', sweep_state: 'running', tune_plan: 'full', tune_step: 8, tune_steps: 13, tune_eta_s: 390, lock_step: 4, lock_steps: 9, sweep_note: 'tuning: holding 1545 MHz · 80% · 41 s (step 8 of 13)', lock_mhz: 0, lock_mhs: 0, lock_w: 0, lock_mhw: 0, unlocked_mhs: 0, unlocked_w: 0, lock_at: 0, lock_note: '' }; + 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: 575, power_pct: 80, power_applied: true, power_limit_w: 460, power_w: search ? 206 : 190, temp_gpu: 58, temp_mem: 66, gclk_mhz: search ? 1545 : 1300, clock_cap_mhz: search ? 1545 : 1300, telemetry_at: t, hash_now: search ? 121.1 : 120.7, hash_avg: 120.4, accepted: 102, sweep_supported: true, sweep_pct: 80, tune_control: true, + ...(search ? searching : { state: 'mining', sweep_state: 'idle', sweep_note: '', tune_line: 'Tuned: 120.7 MH/s at 190 W (0.635 MH/W)', tune_source: 'full', tune_clock_mhz: 1300, sweep_at: t - 7200, sweep_watts: 190, sweep_mhs: 120.7, sweep_eff: 0.635, tune_before_watts: 374, tune_before_mhs: 122.5, tune_curve: ladder, ...locked }) }, + { ...recState.mining.cards[0], key: 'nvidia:1:NVIDIA GeForce RTX 4070', name: 'NVIDIA GeForce RTX 4070', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '5888 cores', vram_mb: 12288, device: '1', identities: 2, ids: ['9a8b7c6d'], power_default_w: 200, power_pct: 80, power_applied: true, power_limit_w: 160, power_w: 149, temp_gpu: 57, temp_mem: 0, gclk_mhz: 2100, clock_cap_mhz: 2100, telemetry_at: t, hash_now: 60.2, hash_avg: 60.0, sweep_supported: true, sweep_pct: 80, tune_control: true, ...searching, tune_step: 7, tune_steps: 12, tune_eta_s: 330, lock_step: 3, lock_steps: 8, sweep_note: 'tuning: holding 2100 MHz · 80% · 22 s (step 7 of 12)' }]; + s.mining.hash_total = search ? 181.3 : 180.9; s.mining.state = 'mining'; s.display_name = 'study-pc'; + s.settings = { ...s.settings, sweep: true, power_control: true, tune_goal: 'efficiency', power_price_pence: 26.32, region: 'gb' }; + } if (scenario === 'nvidia') { 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: 575, power_pct: 80, power_applied: true, power_limit_w: 460, power_w: 441, temp_gpu: 71, temp_mem: 92, telemetry_at: t, hash_now: 412.5, hash_avg: 405.2 }, { ...recState.mining.cards[0], key: 'nvidia:1:NVIDIA GeForce RTX 4070', name: 'NVIDIA GeForce RTX 4070', kind: 'discrete', vendor: 'nvidia', worker: 'CUDA', detail: '5888 cores', vram_mb: 12288, device: '1', identities: 2, ids: ['9a8b7c6d'], power_default_w: 200, power_pct: 80, power_applied: false, power_note: 'nvidia-smi: wanted 160 W, card reports 200 W', power_limit_w: 200, power_w: 188, temp_gpu: 64, temp_mem: 0, telemetry_at: t, hash_now: 98.1, hash_avg: 97.4, state: 'restarting', restart_in_s: 4 },