From a9565036e5befcf4da59c5d5e25d26f9a1155c26 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:31:55 +0000 Subject: [PATCH] ember-heat (0.3.19): the region and price prompt, the heat cards and lines, the cost-against-rent row First run gains step 2 of 3 (the region list, the price per kWh prefilled from the public table and typed, never fetched, the restricted line for Russia's regions and China from future.md 4.4 and 8.3 with the standing sentence); Settings gains Electricity and Heat mode; the Cards strip and every row's Ember line show the duty and the heat in watts; a resting card, the pill and the big button say heat mode; Earnings gains the miner's cost per MH/s-hour at their price beside the bench log's measured rental rate with the verdict. The money symbol follows the region. heat-region.test.mjs checks the Russian entry, the bench-log rate and the words; the mock gains heat and heat-rest. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/ui/app.css | 14 +- app/igneum-app/ui/app.js | 174 +++++++++++++++++++++++-- app/igneum-app/ui/heat-region.test.mjs | 150 +++++++++++++++++++++ app/igneum-app/ui/index.html | 74 ++++++++++- tools/ui-mock/server.mjs | 11 ++ 5 files changed, 402 insertions(+), 21 deletions(-) create mode 100644 app/igneum-app/ui/heat-region.test.mjs diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 996ad5373..05f823426 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -151,7 +151,7 @@ main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overfl body.has-rail main{left:var(--rail)} body.drawer-open main{bottom:var(--drawer-h)} .screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease} -body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block} +body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="region"] #screen-region,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block} @keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}} @media (prefers-reduced-motion:reduce){.screen,.page{animation:none}} #screen-dashboard{padding:22px 0 32px} @@ -395,6 +395,18 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)} .addr-input{width:100%;margin-top:var(--s-2);background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:12px 14px;color:var(--bone);font-size:var(--t-md);letter-spacing:.02em;user-select:text;-webkit-user-select:text} .addr-input.short{width:100px;flex:0 0 100px} .addr-input:focus{outline:none;border-color:var(--ember)} +/* Ember Heat (mission item 7): the region list, the restricted line, the heat line on the strip and the rows, the rent row */ +.addr-input.select{appearance:none;-webkit-appearance:none;max-width:360px;padding-right:36px;background-image:linear-gradient(45deg,transparent 50%,var(--ash) 50%),linear-gradient(135deg,var(--ash) 50%,transparent 50%);background-position:calc(100% - 20px) 50%,calc(100% - 14px) 50%;background-size:6px 6px,6px 6px;background-repeat:no-repeat;cursor:pointer} +.addr-input.select option{background:var(--graphite);color:var(--bone)} +.restricted{font-size:var(--t-md);line-height:1.5;color:var(--bone);background:var(--ember-12);border:1px solid var(--ember-40);border-left:3px solid var(--ember);border-radius:10px;padding:10px 14px;max-width:64ch;margin:0} +.heat-line{font-family:var(--mono);font-size:var(--t-sm)} +.heat-line.heat,.line-text.ok{color:var(--molten)} +.heat-line.rest,.line-text.rest{color:var(--ash)} +.field.grow{flex:1 1 260px;min-width:0} +.gl-tune .n.heat{color:var(--molten)} +.money-row.rent .v{font-size:var(--t-num)} +.money-row.rent.under .v{color:var(--ember)} +.money-row.rent.ask .v{color:var(--ash)} .option:not(.on) .addr-input{display:none} .err{font-size:var(--t-base);color:var(--ember)} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index c9cab06ad..ce95d18a1 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -311,7 +311,7 @@ var View = (function () { var on = !!cd.enabled && !removed && !unusable, st = removed ? 'removed' : unusable ? 'unusable' : (cd.state || 'off'); // the rule (docs/plans/ember-tune.md, 6 October 2026): a card never shows 0 MH/s without a reason word var tuning = st === 'tuning', held = st === 'held'; - var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : tuning ? tuneWord(cd) : held ? (cd.message || 'paused for a job from the team') : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated errors' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st; + var word = removed ? 'removed' : unusable ? ('not usable (' + cd.problem + ')') : !on ? 'off' : st === 'mining' ? 'mining' : tuning ? tuneWord(cd) : held ? (cd.message || 'paused for a job from the team') : st === 'resting' ? 'resting (heat mode)' : st === 'restarting' ? 'restart in ' + (cd.restart_in_s || 0) + ' s' : st === 'waiting' ? 'waiting for the node' : st === 'faulted' ? 'stopped after repeated errors' : st === 'failed' ? 'failed' : st === 'starting' ? 'starting' : st === 'ready' ? 'ready' : st; var tone = removed ? 'off' : unusable ? 'bad' : !on ? 'off' : (st === 'mining' || tuning) ? 'on' : (st === 'failed' || st === 'faulted' || st === 'restarting') ? 'bad' : ''; var hash = on && (st === 'mining' || tuning) ? (cd.hash_now >= 100 ? cd.hash_now.toFixed(0) : (cd.hash_now || 0).toFixed(1)) : ''; var temp = cd.temp_gpu > 0 ? Math.round(cd.temp_gpu) + ' °C' : ''; @@ -340,7 +340,9 @@ var View = (function () { // ---- money (miner-ui-3): £ a day from watts at pence per kWh; null when either is unknown ---- function poundsPerDay(watts, pence) { return watts > 0 && pence > 0 ? watts * 24 / 1000 * pence / 100 : null; } - function money(v) { if (v === null || v === undefined || isNaN(v)) return ''; return '£' + (v >= 100 ? String(Math.round(v)) : v.toFixed(2)); } + // the currency symbol follows the region (Ember Heat, setRegion); £ until a region is chosen + var currency = { code: 'GBP', symbol: '£', minor: 'p', usd: 1.32 }; + function money(v) { if (v === null || v === undefined || isNaN(v)) return ''; return currency.symbol + (v >= 100 ? String(Math.round(v)) : v.toFixed(2)); } // MH per watt: the engine's live figure, else rate over draw function effOf(cd) { if (cd.eff_mhw > 0) return cd.eff_mhw; return cd.power_w > 0 && cd.hash_now > 0 ? cd.hash_now / cd.power_w : 0; } function effText(e) { return e > 0 ? (e >= 10 ? e.toFixed(1) : e.toFixed(2)) + ' MH/W' : ''; } @@ -448,6 +450,7 @@ var View = (function () { if (tc) return { label: 'Tuning', sub: 'mining again in ' + tuneEta(tc.tune_eta_s) + ' · ' + tc.name, cls: 'stop', disabled: true, act: '' }; var heldCards = cards.filter(function (c) { return c.state === 'held'; }).length; if (heldCards && !mining) return { label: 'Held', sub: 'a job from the team is using the cards. Mining restarts when it ends', cls: 'stop', disabled: true, act: '' }; + if (s && s.heat && s.heat.on && s.heat.phase === 'resting' && !mining && !m.paused) return { label: 'Stop mining', sub: 'resting · heat mode holds ' + s.heat.set_c.toFixed(1) + ' °C, heats again in ' + (s.heat.until_s < 45 ? 'under a minute' : Math.round(s.heat.until_s / 60) + ' min'), cls: 'stop', disabled: false, act: 'pause' }; if (!on) return { label: 'Start mining', sub: cards.length ? 'switch a card on in Cards first' : 'no card this app can use', cls: '', disabled: true, act: '' }; if (m.paused) return { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' }; if (m.state === 'mining') return { label: 'Stop mining', sub: 'mining on ' + mining + ' of ' + on + ' card' + (on === 1 ? '' : 's'), cls: 'stop', disabled: false, act: 'pause' }; @@ -666,6 +669,7 @@ var View = (function () { if (tuningCard(m.cards)) return { text: 'Tuning', tone: 'on' }; if (m.state === 'mining') return { text: 'Mining · ' + (m.hash_total >= 100 ? m.hash_total.toFixed(0) : (m.hash_total || 0).toFixed(1)) + ' MH/s', tone: 'on' }; if (m.state === 'paused') return { text: 'Paused', tone: '' }; + if (s.heat && s.heat.on && s.heat.phase === 'resting') return { text: 'Resting · heat mode', tone: '' }; if (n.state === 'syncing') return { text: 'Syncing the node', tone: '' }; if (n.state === 'starting') return { text: 'Starting the node', tone: '' }; if (n.state === 'restarting') return { text: 'Node restarting', tone: 'bad' }; @@ -698,6 +702,7 @@ var View = (function () { 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 }; }); 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 === 'paused') { out.label = 'Tuning paused by Igneum'; out.sub = ''; return out; } if (tw.kind === 'stopped') { out.label = tw.text; return out; } @@ -724,6 +729,85 @@ var View = (function () { var ppd = poundsPerDay(w, pence); return 'Ember saves ' + Math.round(w) + ' W' + (ppd !== null ? ', ' + money(ppd) + ' a day' : '') + ' across ' + n + ' tuned card' + (n === 1 ? '' : 's'); } + // ---- Ember Heat (mission item 7, 7 October 2026; docs/plans/ember-heat.md): the region table and the price prompt, + // the restricted line, the cost-against-rent line, the heat words ---- + // The prices are the public table's rows (docs/analysis/mission/future.md 4.1: Ofgem, Eurostat, EIA), typed into the + // app as defaults the miner edits; nothing is fetched. The dollar rates are the ones future.md 3.4 implies (USD 1.32 per + // pound, 1.08 per euro), approximate. The restricted entries are future.md 4.4 and 8.3 item 7: Russia (ten regions, the + // Moscow region, the seasonal three) and China. Nothing is added to that list here. + var RESTRICTED_SENTENCE = 'Mining may be restricted where you are. You are responsible for checking.'; + var CURRENCIES = { GBP: { code: 'GBP', symbol: '£', minor: 'p', usd: 1.32 }, EUR: { code: 'EUR', symbol: '€', minor: 'c', usd: 1.08 }, USD: { code: 'USD', symbol: '$', minor: 'c', usd: 1 } }; + var EUROSTAT = 'Eurostat, second half of 2025'; + var REGIONS = [ + { code: 'gb', name: 'United Kingdom', cur: 'GBP', price: 26.32, source: 'Ofgem cap, 1 October to 31 December 2026' }, + { code: 'ie', name: 'Ireland', cur: 'EUR', price: 40.42, source: EUROSTAT }, + { code: 'de', name: 'Germany', cur: 'EUR', price: 38.69, source: EUROSTAT }, + { code: 'be', name: 'Belgium', cur: 'EUR', price: 34.99, source: EUROSTAT }, + { code: 'eu', name: 'European Union, other', cur: 'EUR', price: 28.96, source: EUROSTAT + ', the EU average' }, + { code: 'hu', name: 'Hungary', cur: 'EUR', price: 10.82, source: EUROSTAT + ', in euro cents' }, + { code: 'mt', name: 'Malta', cur: 'EUR', price: 12.82, source: EUROSTAT }, + { code: 'bg', name: 'Bulgaria', cur: 'EUR', price: 13.55, source: EUROSTAT + ', in euro cents' }, + { code: 'us', name: 'United States', cur: 'USD', price: 17.7, source: 'EIA average, second half of 2025' }, + { code: 'kz', name: 'Kazakhstan', cur: 'USD', price: 4, source: 'about 4 c, a secondary source, approximate' }, + { code: 'py', name: 'Paraguay', cur: 'USD', price: 4.4, source: 'ANDE crypto tariff, 4.4 to 6 c; every crypto contract ends 31 December 2027' }, + { code: 'ir', name: 'Iran', cur: 'USD', price: 1, source: 'licensed mining tariff, about 1 c, a secondary source, approximate' }, + { code: 'et', name: 'Ethiopia', cur: 'USD', price: 2, source: '2 to 5.3 c, a secondary source' }, + { code: 'ng', name: 'Nigeria', cur: 'USD', price: 4.8, source: 'hosted, 4.8 c, a secondary source' }, + { code: 'ru-mow', name: 'Russia, Moscow region', cur: 'USD', price: 0, restricted: 'Mining is banned in the Moscow region from 15 August 2026 to 2032.' }, + { code: 'ru-irk', name: 'Russia, Irkutsk', cur: 'USD', price: 0, restricted: 'Irkutsk has a seasonal mining ban.' }, + { code: 'ru-bur', name: 'Russia, Buryatia', cur: 'USD', price: 0, restricted: 'Buryatia has a seasonal mining ban.' }, + { code: 'ru-zab', name: 'Russia, Zabaikalsky', cur: 'USD', price: 0, restricted: 'Zabaikalsky has a seasonal mining ban.' }, + { code: 'ru', name: 'Russia, other region', cur: 'USD', price: 0, restricted: 'Mining is banned in ten regions from 1 January 2025 to 15 March 2031, in the Moscow region from 15 August 2026, and seasonally in Irkutsk, Buryatia and Zabaikalsky. This app does not carry the list of ten.' }, + { code: 'cn', name: 'China', cur: 'USD', price: 0, restricted: 'Mining is illegal in China. The joint Notice of 6 February 2026 reaffirmed it.' }, + { code: 'other', name: 'Somewhere else', cur: 'USD', price: 0 } + ]; + function regionOf(code) { for (var i = 0; i < REGIONS.length; i++) if (REGIONS[i].code === code) return REGIONS[i]; return null; } + function currencyOf(code) { var r = regionOf(code); return CURRENCIES[r ? r.cur : 'GBP'] || CURRENCIES.GBP; } + // the money symbol follows the region from here on (every £ figure on every page) + function setRegion(code) { currency = currencyOf(code); return currency; } + // the restricted line for a region: the standing sentence plus the entry, or '' for a region with no entry + function restrictedLine(code) { var r = regionOf(code); return r && r.restricted ? RESTRICTED_SENTENCE + ' ' + r.restricted : ''; } + // the price note under the field: where the default came from, or that the miner types their own + function priceNote(code) { + var r = regionOf(code), c = currencyOf(code); + if (!r) return 'Choose a region to see a typical price. Every ' + c.symbol + ' figure uses what you type here. Nothing is fetched.'; + return (r.price > 0 ? 'Typical: ' + r.price + ' ' + c.minor + ' per kWh (' + r.source + '). ' : 'No table price for this region. ') + 'Type what you pay, in ' + (c.code === 'GBP' ? 'pence' : c.code === 'EUR' ? 'euro cents' : 'US cents') + ' per kWh. Nothing is fetched.'; + } + // the measured rental rate of hash (docs/bench-log.md, "Rental cost of hash, 6 October 2026"); the test + // heat-region.test.mjs reads the bench log and fails when this number drifts from it + var RENT = { usd_per_mhs_hour: 0.0117, measured: '6 October 2026', source: 'RunPod community pods, 38 pods at list price, 1,748 MH/s for USD 20.44 an hour' }; + // the miner's own cost of hash: watts per MH/s at the price, in the region's minor unit per MH/s-hour, against the rent. + // Live numbers while mining; the last tune's point otherwise; nothing without a draw reading or a price. + function rentLine(cards, price, code) { + var c = currencyOf(code), w = 0, h = 0, live = false; + (cards || []).filter(present).forEach(function (cd) { + if (cd.state === 'mining' && cd.power_w > 0 && cd.hash_now > 0) { w += cd.power_w; h += cd.hash_now; live = true; } + }); + if (!(h > 0)) { w = 0; (cards || []).filter(present).forEach(function (cd) { if (cd.sweep_watts > 0 && cd.sweep_mhs > 0) { w += cd.sweep_watts; h += cd.sweep_mhs; } }); } + var out = { kind: 'none', text: '', sub: '', cost: 0, usd: 0, ratio: 0, live: live, rentUsd: RENT.usd_per_mhs_hour }; + if (!(h > 0 && w > 0)) { out.text = 'Your cost of hash: no reading yet'; out.sub = 'needs a card mining with a power reading, or a finished tune. Rented hash costs USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour (' + RENT.source + ', ' + RENT.measured + ').'; return out; } + if (!(price > 0)) { out.kind = 'price'; out.text = 'Your cost of hash: set a price'; out.sub = Math.round(w) + ' W for ' + (h >= 100 ? h.toFixed(0) : h.toFixed(1)) + ' MH/s is ' + (w / h).toFixed(2) + ' W per MH/s. Set your electricity price to compare it with rented hash at USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour.'; return out; } + var cost = w / h / 1000 * price; // minor units per MH/s-hour + var usd = cost / 100 * c.usd, ratio = RENT.usd_per_mhs_hour / usd; + out.kind = 'line'; out.cost = cost; out.usd = usd; out.ratio = ratio; + var costText = cost < 0.1 ? cost.toFixed(3) : cost.toFixed(2); + out.text = costText + ' ' + c.minor + ' per MH/s-hour'; + var verdict = ratio >= 1.05 ? 'Yours is ' + (ratio >= 10 ? Math.round(ratio) : ratio.toFixed(1)) + 'x cheaper than rented hash.' : ratio <= 0.95 ? 'Rented hash undercuts your card by ' + ((1 / ratio) >= 10 ? Math.round(1 / ratio) : (1 / ratio).toFixed(1)) + 'x.' : 'Level with rented hash.'; + out.verdict = verdict; + out.sub = 'your cost of hash at ' + price + ' ' + c.minor + '/kWh and ' + (w / h).toFixed(2) + ' W per MH/s' + (live ? '' : ' (the last tune)') + (c.code === 'USD' ? '' : ', about USD ' + usd.toFixed(4)) + '. Rented hash: USD ' + RENT.usd_per_mhs_hour + ' per MH/s-hour (' + RENT.source + ', ' + RENT.measured + '). ' + verdict; + return out; + } + // heat mode on the Ember strip and the card rows: h = state.heat + function heatLine(h) { + if (!h || !h.on) return { text: '', tone: '' }; + var room = h.room_source === 'typed' ? 'room ' + h.room_c.toFixed(1) + ' °C' : h.room_source === 'card' ? 'room about ' + Math.round(h.room_c) + ' °C' : 'no room reading yet'; + var heat = h.heat_w > 0 ? Math.round(h.heat_w) + ' W of heat' : h.phase === 'resting' ? 'resting' + (h.full_w > 0 ? ', ' + Math.round(h.full_w) + ' W when heating' : '') : h.full_w > 0 ? Math.round(h.full_w) + ' W when heating' : 'no draw reading'; + var duty = Math.round((h.duty_hour > 0 ? h.duty_hour : h.duty) * 100); + return { text: 'Heat mode: holding ' + h.set_c.toFixed(1) + ' °C · ' + room + ' · heating ' + duty + '% of the time · ' + heat, tone: h.phase === 'resting' ? 'rest' : h.phase === 'heating' ? 'heat' : '' }; + } + // the short form on a card row's Ember line + function heatRowWords(h) { if (!h || !h.on) return ''; return 'heat ' + Math.round((h.duty_hour > 0 ? h.duty_hour : h.duty) * 100) + '% · ' + (h.phase === 'resting' ? 'resting' : h.heat_w > 0 ? Math.round(h.heat_w) + ' W of heat' : 'heating'); } + // ---- the engine's 0.3.16 fields (ember-tune fd03f35): mining.watts_total, mining.pounds_per_day, address.balance_wei // (string | null), address.balance_age_s (-1 until read), address.balance_note, address.price_gbp_per_ign // (null until a signed manifest carries a price), the chain scene's reply.source ("node" | "site") ---- @@ -943,7 +1027,7 @@ var View = (function () { // the confirmations' words (in place, never a dialog) 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 { 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, 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, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords }; + return { 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, 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, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, 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 @@ -1046,7 +1130,7 @@ if (typeof document !== 'undefined') (function () { document.querySelectorAll('#theme-seg .seg-b').forEach(function (b) { var on = b.dataset.theme === t; b.classList.toggle('on', on); b.setAttribute('aria-checked', on ? 'true' : 'false'); }); store('igneum.theme', t === 'system' ? null : t); } - applyTheme(stored('igneum.theme') || 'system'); + applyTheme(params.get('theme') || stored('igneum.theme') || 'system'); // ?theme=light|dark for screenshots $('theme-seg').addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (b) applyTheme(b.dataset.theme); }); // ---------- screens (setup) and pages (the dashboard) ---------- @@ -1060,6 +1144,8 @@ if (typeof document !== 'undefined') (function () { $('page-title').hidden = !dash; if (!dash && drawerOpen()) setDrawer(false); if (p === 'cards' && !cardsSeen) { cardsSeen = true; api('api/detect', {}); } + // the region step; ?region= preselects one for screenshots + if (p === 'region') { fillRegions($('region-select')); var rc = params.get('region') || regionNow(); $('region-select').value = rc; regionWords(rc, $('region-price'), $('region-unit'), $('region-note'), $('region-restricted'), !(priceNow() > 0)); if (priceNow() > 0) $('region-price').value = String(priceNow()); } if (dash) { showPage(page); mountDag(); } api('api/phase', { phase: p }); } @@ -1090,7 +1176,35 @@ if (typeof document !== 'undefined') (function () { $('btn-begin').addEventListener('click', function () { show('cards'); }); $('btn-cards-next').addEventListener('click', function () { var choices = readCardRows($('cards-list')); - if (choices.length) api('api/cards', { cards: choices }).then(function () { show('address'); }); else show('address'); + if (choices.length) api('api/cards', { cards: choices }).then(function () { show('region'); }); else show('region'); + }); + // ---------- the region and price step (Ember Heat): the list, a typed price never fetched, the restricted line ---------- + function fillRegions(sel) { + if (sel.options.length) return; + sel.innerHTML = '' + View.REGIONS.map(function (r) { return ''; }).join(''); + } + // the price field, unit, note and restricted line follow the chosen region; a typed price is kept unless `prefill` + function regionWords(code, priceEl, unitEl, noteEl, restrictedEl, prefill) { + var r = View.regionOf(code), c = View.currencyOf(code); + if (prefill && priceEl) priceEl.value = r && r.price > 0 ? String(r.price) : ''; + if (priceEl) priceEl.placeholder = r && r.price > 0 ? String(r.price) : ''; + if (unitEl) setText(unitEl.id, (c.code === 'GBP' ? 'pence' : c.code === 'EUR' ? 'euro cents' : 'US cents') + ' per kWh'); + if (noteEl) setText(noteEl.id, View.priceNote(code)); + var line = View.restrictedLine(code); + if (restrictedEl) { restrictedEl.hidden = !line; setText(restrictedEl.id, line); } + } + function regionNow() { return (state && state.settings && state.settings.region) || stored('igneum.region') || ''; } + function saveRegion(code, price) { + store('igneum.region', code || null); store('igneum.price', price > 0 ? price : null); + return api('api/region', { region: code, price_pence: price }).catch(function () { return api('api/tune/goal', { price_pence: price }); }); + } + $('region-select').addEventListener('change', function () { regionWords(this.value, $('region-price'), $('region-unit'), $('region-note'), $('region-restricted'), true); }); + $('btn-region-back').addEventListener('click', function () { show('cards'); }); + $('btn-region-skip').addEventListener('click', function () { show('address'); }); + $('btn-region-next').addEventListener('click', function () { + var code = $('region-select').value, p = parseFloat($('region-price').value); + if (!(p >= 0)) p = 0; + saveRegion(code, p).then(function () { show('address'); }).catch(function () { show('address'); }); }); $('btn-cards-retry').addEventListener('click', function () { cardsSeen = false; $('cards-detecting').hidden = false; api('api/detect', {}); }); $('btn-address-back').addEventListener('click', function () { show('cards'); }); @@ -1246,13 +1360,30 @@ if (typeof document !== 'undefined') (function () { $('s-sweep').addEventListener('change', function () { api('api/sweep/enable', { on: this.checked }).then(function (r) { if (r.ok) toast($('s-sweep').checked ? 'Ember Tune on. Once after install, then every 7 days' : 'Ember Tune off'); }); }); $('s-climb').addEventListener('change', function () { var on = this.checked; api('api/tune/goal', { climb: on }).then(function (r) { if (!r || !r.ok) { toast('Needs the Ember Tune update'); $('s-climb').checked = !on; } }); }); function savePrice() { - var p = parseFloat($('s-price').value); - if (!(p >= 0)) { toast('Type a price in pence per kWh, for example 28'); return; } - store('igneum.price', p > 0 ? p : null); - api('api/tune/goal', { price_pence: p }).then(function () { toast(p > 0 ? 'Price saved: ' + p + 'p per kWh' : 'Price cleared'); if (state) renderPage(state); }).catch(function () { toast(p > 0 ? 'Price saved on this window: ' + p + 'p per kWh' : 'Price cleared'); if (state) renderPage(state); }); + var p = parseFloat($('s-price').value), code = $('s-region').value, c = View.currencyOf(code); + if (!(p >= 0)) { toast('Type a price in ' + c.minor + ' per kWh, for example ' + (View.regionOf(code) && View.regionOf(code).price || 26.32)); return; } + saveRegion(code, p).then(function () { toast(p > 0 ? 'Price saved: ' + p + ' ' + c.minor + ' per kWh' : 'Price cleared'); if (state) renderPage(state); }).catch(function () { toast(p > 0 ? 'Price saved on this window: ' + p + ' ' + c.minor + ' per kWh' : 'Price cleared'); if (state) renderPage(state); }); } $('s-price-save').addEventListener('click', savePrice); $('s-price').addEventListener('keydown', function (e) { if (e.key === 'Enter') savePrice(); }); + $('s-region').addEventListener('change', function () { regionWords(this.value, $('s-price'), $('s-price-unit'), $('s-price-note'), $('s-region-restricted'), true); }); + // ---------- Heat mode (Ember Heat) ---------- + $('s-heat').addEventListener('change', function () { var on = this.checked; api('api/heat', { on: on }).then(function (r) { if (r.ok) toast(on ? 'Heat mode on. The cards heat for a share of every 10 minutes' : 'Heat mode off'); else { toast(r.error || 'could not change'); $('s-heat').checked = !on; } }); }); + function saveHeat() { + var set = parseFloat($('s-heat-set').value), body = { schedule: $('s-heat-schedule').value.trim(), tz_min: -new Date().getTimezoneOffset() }; + if ($('s-heat-set').value !== '') { if (!(set >= 5 && set <= 30)) { toast('The set point is 5 to 30 degrees'); return; } body.set_c = set; } + api('api/heat', body).then(function (r) { toast(r.ok ? 'Heat settings saved' : (r.error || 'could not save')); if (state) renderPage(state); }); + } + $('s-heat-save').addEventListener('click', saveHeat); + $('s-heat-schedule').addEventListener('keydown', function (e) { if (e.key === 'Enter') saveHeat(); }); + $('s-heat-set').addEventListener('keydown', function (e) { if (e.key === 'Enter') saveHeat(); }); + function saveRoom() { + var c = parseFloat($('s-heat-room').value); + if (!(c >= -20 && c <= 50)) { toast('A room reading is -20 to 50 degrees'); return; } + api('api/heat', { room_c: c }).then(function (r) { toast(r.ok ? 'Room reading used: ' + c + ' °C for the next two hours' : (r.error || 'could not save')); $('s-heat-room').value = ''; if (state) renderPage(state); }); + } + $('s-heat-room-save').addEventListener('click', saveRoom); + $('s-heat-room').addEventListener('keydown', function (e) { if (e.key === 'Enter') saveRoom(); }); $('s-trust').addEventListener('change', function () { var on = this.checked; $('s-trust').checked = !on; $('ask-trust-text').textContent = View.trustAsk(on); $('ask-trust').hidden = false; $('ask-trust').dataset.on = on ? '1' : ''; }); $('ask-trust-no').addEventListener('click', function () { $('ask-trust').hidden = true; }); $('ask-trust-yes').addEventListener('click', function () { @@ -1850,7 +1981,7 @@ if (typeof document !== 'undefined') (function () { var subs = []; if (r.sub) subs.push('
' + esc(r.sub) + '
'); if (cells.noReading && r.on) subs.push('
' + esc(cells.noReading) + '
'); - var tune = em.kind === 'none' ? '' : '
' + flameHtml(em.fill, em.kind) + '' + esc(em.label) + '' + (em.sub ? '' + esc(em.sub) + '' : '') + (em.savingText ? '' + esc(em.savingText) + '' : '') + (em.kind === 'running' ? '' : '') + '
'; + var tune = em.kind === 'none' ? '' : '
' + flameHtml(em.fill, em.kind) + '' + esc(em.label) + '' + (em.sub ? '' + esc(em.sub) + '' : '') + (em.savingText ? '' + esc(em.savingText) + '' : '') + (em.heat ? '' + esc(em.heat) + '' : '') + (em.kind === 'running' ? '' : '') + '
'; return '
' + '
' + badgeHtml({ vendor: r.vendor }) + '
' + '
' + esc(r.name) + ' ' + esc(r.kindWord) + '
' + @@ -1927,11 +2058,11 @@ if (typeof document !== 'undefined') (function () { var mining = presentCards.filter(function (c) { return c.state === 'mining'; }).length; // the live total: rate, watts, £ a day, blocks setText('t-hash', m.hash_total >= 100 ? m.hash_total.toFixed(0) : m.hash_total.toFixed(1)); - setText('t-hash-sub', m.state === 'paused' ? 'paused' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'card starting' : 'waiting for the node') : m.state === 'idle' ? 'no card mining' : m.state); + setText('t-hash-sub', m.state === 'paused' ? 'paused' : (s.heat && s.heat.on && s.heat.phase === 'resting' && !mining) ? 'resting · heat mode' : mining ? (mining + ' of ' + presentCards.length + ' card' + (presentCards.length === 1 ? '' : 's') + ' mining') : m.state === 'waiting' ? (n.synced ? 'card starting' : 'waiting for the node') : m.state === 'idle' ? 'no card mining' : m.state); var watts = View.fleetWatts(m, m.cards), price = priceNow(), eff = watts > 0 && m.hash_total > 0 ? View.effText(m.hash_total / watts) : ''; setHidden('t-power-cell', !(watts > 0)); setText('t-power', String(Math.round(watts))); setText('t-power-sub', eff || 'drawn now'); var ppd = View.fleetPounds(m, m.cards, price); - setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, pence per kWh' : 'electricity at ' + price + 'p/kWh'); + setHidden('t-money-cell', !(watts > 0)); setText('t-money', ppd === null ? 'Set a price' : money(ppd)); $('t-money').classList.toggle('ask-price', ppd === null); setText('t-money-sub', ppd === null ? 'in Settings, per kWh' : 'electricity at ' + price + View.currencyOf(regionNow()).minor + '/kWh'); setText('t-blocks', withCommas(m.accepted_session)); setText('t-blocks-sub', 'this run · ' + withCommas(m.accepted_total) + ' lifetime' + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : '')); // the rows: every card, on or off; rebuilt only when their words change and never under a hand on a slider var rows = shown.map(View.cardRow), now = Date.now(); @@ -1969,6 +2100,8 @@ if (typeof document !== 'undefined') (function () { var sched = View.schedule(s.mining.cards, s, s.now); setText('tune-schedule', sched); setText('s-tune-schedule', sched); 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 : ''); var nvOff = cards.some(function (c) { return c.vendor === 'nvidia' && View.present(c); }) && !st.power_control; setHidden('m-power-row', !nvOff); if (document.activeElement !== $('m-power-control')) $('m-power-control').checked = !!st.power_control; @@ -2026,8 +2159,11 @@ if (typeof document !== 'undefined') (function () { if (bw.text) { setText('e-earned', bw.pounds !== null ? money(bw.pounds) : bw.text); setText('e-earned-sub', bw.pounds !== null ? 'at the signed price · ' + bw.text + ' ' + bw.sub : (bw.text === 'not read yet' ? 'balance ' + bw.sub : bw.sub + '. Nothing is bought or sold on devnet')); $('e-earned').title = 'address.balance_wei: the node\u2019s eth_getBalance of the rewards address'; } else { setText('e-earned', 'balance: not on this engine'); setText('e-earned-sub', 'the wallet shows it'); } setText('e-cost', ppd === null ? (watts > 0 ? Math.round(watts) + ' W' : 'no draw') : money(ppd) + ' a day'); - setText('e-cost-sub', ppd === null ? (watts > 0 ? 'electricity: set a price to see the cost' : 'electricity: no card reports its draw') : 'electricity at ' + price + 'p/kWh for ' + Math.round(watts) + ' W'); + setText('e-cost-sub', ppd === null ? (watts > 0 ? 'electricity: set a price to see the cost' : 'electricity: no card reports its draw') : 'electricity at ' + price + View.currencyOf(regionNow()).minor + '/kWh for ' + Math.round(watts) + ' W'); setHidden('e-price-btn', !(watts > 0 && ppd === null)); + var rent = View.rentLine(m.cards, price, regionNow()); + setText('e-rent', rent.text); setText('e-rent-sub', rent.sub); + $('e-rent-row').classList.toggle('under', rent.kind === 'line' && rent.ratio <= 0.95); $('e-rent-row').classList.toggle('ask', rent.kind === 'price'); if (document.activeElement !== $('s-devfee')) $('s-devfee').checked = !!(s.settings && s.settings.dev_fee); setText('s-devfee-text', s.settings.dev_fee ? 'Dev fee: 1 block in 100 pays the people who make this app' : 'Dev fee off. Every block pays your address'); setText('r-devfee-line', s.settings.dev_fee ? 'The network itself takes nothing. This is the app’s fee, the same way every mining app takes one' + (m.fee_total ? ' (' + withCommas(m.fee_total) + ' block' + (m.fee_total === 1 ? '' : 's') + ' so far)' : '') + '.' : 'The network itself takes nothing either.'); @@ -2074,7 +2210,18 @@ if (typeof document !== 'undefined') (function () { setText('s-profile-text', View.profileWords(!!st.profile_public)); setHidden('s-climb-row', st.tune_climb === undefined); setText('s-power-note', st.tuning_off ? ' ' + (st.tuning_note || '') : (st.power_note ? ' ' + st.power_note : '')); + fillRegions($('s-region')); + var rc = regionNow(); + if (document.activeElement !== $('s-region') && $('s-region').value !== rc) { $('s-region').value = rc; } if (document.activeElement !== $('s-price')) { var p = priceNow(); $('s-price').value = p > 0 ? String(p) : ''; } + if (document.activeElement !== $('s-region')) regionWords($('s-region').value, null, $('s-price-unit'), $('s-price-note'), $('s-region-restricted'), false); + var h = s.heat || {}; + sw('s-heat', st.heat_on); + setText('s-heat-line', h.note || ''); + $('s-heat-line').classList.toggle('ok', h.phase === 'heating'); $('s-heat-line').classList.toggle('rest', h.phase === 'resting'); + if (document.activeElement !== $('s-heat-set')) $('s-heat-set').value = st.heat_set_c > 0 ? String(st.heat_set_c) : ''; + if (document.activeElement !== $('s-heat-schedule')) $('s-heat-schedule').value = st.heat_schedule || ''; + setText('s-heat-room-note', (st.heat_room_at > 0 ? 'Your reading ' + st.heat_room_c + ' °C, ' + View.ago(s.now - st.heat_room_at) + (s.now - st.heat_room_at > 7200 ? ' (older than two hours, so the card reads the room now)' : '') + '. ' : '') + 'A reading you type is the room for two hours. Without one the card\'s own sensor reads the room after 3 minutes of rest, within about 3 degrees' + (h.offset_learned ? ' (idle offset learned: ' + h.offset_c.toFixed(1) + ' degrees)' : '') + '. Typing a reading while the cards rest teaches the app the card\'s idle offset.'); renderTuneCard(s); if (document.activeElement !== $('s-name')) $('s-name').value = s.display_name || ''; setText('s-mid', 'machine id ' + (s.machine_id || '').slice(0, 8)); @@ -2287,6 +2434,7 @@ if (typeof document !== 'undefined') (function () { } function render(s) { state = s; stateAt = performance.now(); + View.setRegion((s.settings && s.settings.region) || stored('igneum.region') || ''); if (forcedUpdate) { s.update = sampleUpdate(forcedUpdate); if (forcedUpdate === 'updated') { s.version = '0.3.14'; s.uptime_s = 20; } } if (forcedJob) s.jobs = sampleJob(forcedJob, s.now || 0); if (params.get('tune')) sampleTune(s, params.get('tune')); diff --git a/app/igneum-app/ui/heat-region.test.mjs b/app/igneum-app/ui/heat-region.test.mjs new file mode 100644 index 000000000..f37554c81 --- /dev/null +++ b/app/igneum-app/ui/heat-region.test.mjs @@ -0,0 +1,150 @@ +// node --test app/igneum-app/ui/heat-region.test.mjs (no dependencies; the pre-push gate runs it) +// Ember Heat (mission item 7, docs/plans/ember-heat.md): the region prompt shows the right restricted entry for a Russian +// region and nothing for the rest; the rent line reads the bench log's measured rate; the heat words name the duty and +// the watts; the money symbol follows the region. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const src = readFileSync(join(here, 'app.js'), 'utf8'); +const mod = { exports: {} }; +new Function('module', src)(mod); +const V = mod.exports.View; + +const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over }); + +test('the region prompt shows the restricted entry for a Russian region, with the standing sentence first', () => { + const line = V.restrictedLine('ru-mow'); + assert.ok(line.startsWith(V.RESTRICTED_SENTENCE), line); + assert.equal(V.RESTRICTED_SENTENCE, 'Mining may be restricted where you are. You are responsible for checking.'); + assert.ok(line.includes('Moscow region from 15 August 2026 to 2032'), line); + assert.ok(V.restrictedLine('ru').includes('ten regions from 1 January 2025 to 15 March 2031'), 'the other-region entry carries the ten-region ban'); + assert.ok(V.restrictedLine('ru').includes('does not carry the list of ten'), 'the app says what it does not know'); + for (const seasonal of ['ru-irk', 'ru-bur', 'ru-zab']) assert.ok(V.restrictedLine(seasonal).includes('seasonal mining ban'), seasonal); + assert.ok(V.restrictedLine('cn').includes('illegal in China'), 'China is the other entry of future.md 8.3 item 7'); +}); + +test('no region outside the restricted list of future.md 8.3 gets a line', () => { + const restricted = V.REGIONS.filter((r) => r.restricted).map((r) => r.code).sort(); + assert.deepEqual(restricted, ['cn', 'ru', 'ru-bur', 'ru-irk', 'ru-mow', 'ru-zab'], 'Russia (the ten regions, Moscow, the seasonal three) and China, nothing else'); + for (const code of ['gb', 'de', 'us', 'kz', 'py', 'ir', 'other', '']) assert.equal(V.restrictedLine(code), '', code); +}); + +// the research file and the bench log are internal (tools/ci/export-exclude.txt): a tree without them skips the read +const research = join(here, '../../../docs/analysis/mission/future.md'); +const benchLog = join(here, '../../../docs/bench-log.md'); + +test('the restricted entries are the ones the research file states (future.md section 4.4 and 8.3)', (t) => { + if (!existsSync(research)) { t.skip('docs/analysis/mission/future.md is not in this tree'); return; } + const future = readFileSync(research, 'utf8'); + assert.ok(future.includes('mining banned in 10 regions 1 Jan 2025 to 15 Mar 2031'), 'the ten-region ban and its dates'); + assert.ok(future.includes('Moscow region from 15 Aug 2026 to 2032'), 'the Moscow region entry and its dates'); + assert.ok(future.includes('seasonal bans in Irkutsk, Buryatia, Zabaikalsky'), 'the seasonal three'); + assert.ok(/China \| illegal; joint Notice 6 Feb 2026/.test(future), 'China'); + assert.ok(future.includes('"mining may be restricted where you are; you are responsible for checking"'), 'the standing sentence'); +}); + +test('the price prompt defaults from the public table and says it is typed, never fetched', () => { + assert.equal(V.regionOf('gb').price, 26.32); + assert.ok(V.regionOf('gb').source.includes('Ofgem')); + assert.equal(V.regionOf('de').price, 38.69); + assert.equal(V.regionOf('us').price, 17.7); + assert.equal(V.regionOf('ru-mow').price, 0, 'no table price for a Russian region: the miner types one'); + assert.ok(V.priceNote('gb').includes('Typical: 26.32 p per kWh')); + assert.ok(V.priceNote('gb').endsWith('Nothing is fetched.')); + assert.ok(V.priceNote('ru').startsWith('No table price for this region.')); + assert.ok(V.priceNote('').includes('Nothing is fetched.')); + assert.ok(!src.includes('fetch(\'https://') && !src.includes('exchangerate'), 'the UI fetches no price and no rate'); +}); + +test('the money symbol follows the region: pounds, euros, dollars', () => { + V.setRegion('gb'); assert.equal(V.money(1.5), '£1.50'); + V.setRegion('de'); assert.equal(V.money(1.5), '€1.50'); assert.equal(V.currencyOf('de').minor, 'c'); + V.setRegion('us'); assert.equal(V.money(1.5), '$1.50'); + V.setRegion('ru-mow'); assert.equal(V.currencyOf('ru-mow').code, 'USD', 'a region without a table currency types US cents'); + V.setRegion(''); assert.equal(V.money(1.5), '£1.50', 'no region chosen: pounds, as before'); +}); + +test('the rent line reads the bench log: the measured USD per MH/s-hour in the app is the bench log\'s number', (t) => { + if (!existsSync(benchLog)) { t.skip('docs/bench-log.md is not in this tree'); return; } + const log = readFileSync(benchLog, 'utf8'); + const section = log.slice(log.lastIndexOf('## Rental cost of hash')); + assert.ok(section.length > 0, 'the bench log carries a Rental cost of hash entry'); + const m = section.match(/USD ([0-9.]+) per MH\/s-hour/); + assert.ok(m, 'the entry states USD x per MH/s-hour'); + assert.equal(V.RENT.usd_per_mhs_hour, parseFloat(m[1]), 'the app carries the bench log\'s measured rate; update RENT when the log moves'); + const date = section.match(/## Rental cost of hash, ([0-9]+ [A-Za-z]+ [0-9]{4})/); + assert.ok(date, 'the entry is dated'); + assert.equal(V.RENT.measured, date[1]); +}); + +test('the cost-against-rent line: a UK card at the Ofgem price is about 10x cheaper than rented hash (future.md 3.4)', () => { + V.setRegion('gb'); + // 3.27 W per MH/s at 26.32 p: 0.0861 p per MH/s-hour, about USD 0.00114, 10x under 0.0117 + const r = V.rentLine([card({ power_w: 327, hash_now: 100 })], 26.32, 'gb'); + assert.equal(r.kind, 'line'); + assert.ok(Math.abs(r.cost - 0.0861) < 0.001, r.cost); + assert.ok(Math.abs(r.usd - 0.00114) < 0.0001, r.usd); + assert.equal(r.text, '0.086 p per MH/s-hour'); + assert.ok(r.ratio > 9.5 && r.ratio < 11, r.ratio); + assert.ok(r.sub.includes('Rented hash: USD 0.0117 per MH/s-hour'), r.sub); + assert.ok(r.sub.includes('6 October 2026'), r.sub); + assert.ok(r.verdict.startsWith('Yours is 10'), r.verdict); + assert.ok(r.sub.includes('about USD 0.0011'), r.sub); +}); + +test('the cost-against-rent line says when rented hash undercuts the card, and what it needs when it cannot say', () => { + // a 3080-class card at 12 GB and a dear tariff: 6 W per MH/s at 60 c (euro) = 0.36 c = USD 0.0039: still under the rent + const under = V.rentLine([card({ power_w: 300, hash_now: 50 })], 60, 'de'); + assert.equal(under.kind, 'line'); + assert.ok(under.ratio > 2.5 && under.ratio < 3.5, under.ratio); + // the day idle datacentre hash sets the rent at 0.00016 the line flips (the table in future.md 3.4): here, a card at + // 20 W per MH/s on 100 c power = 2 c = USD 0.0216, dearer than the rent + const dear = V.rentLine([card({ power_w: 400, hash_now: 20 })], 100, 'us'); + assert.equal(dear.kind, 'line'); + assert.ok(dear.ratio < 1, dear.ratio); + assert.ok(dear.verdict.startsWith('Rented hash undercuts your card by'), dear.verdict); + assert.equal(dear.sub.includes('about USD'), false, 'a dollar region needs no conversion'); + // no price: the W per MH/s and the ask + const ask = V.rentLine([card()], 0, 'gb'); + assert.equal(ask.kind, 'price'); + assert.ok(ask.sub.includes('W per MH/s'), ask.sub); + // not mining and never tuned: nothing to say, the rent still stated + const none = V.rentLine([card({ state: 'off', hash_now: 0, power_w: 0 })], 26.32, 'gb'); + assert.equal(none.kind, 'none'); + assert.ok(none.sub.includes('USD 0.0117')); + // not mining but tuned: the tune's point stands in, and the line says so + const tuned = V.rentLine([card({ state: 'off', hash_now: 0, power_w: 0, sweep_watts: 290, sweep_mhs: 122.3 })], 26.32, 'gb'); + assert.equal(tuned.kind, 'line'); + assert.ok(tuned.sub.includes('(the last tune)'), tuned.sub); +}); + +test('the heat words: the strip line names the set point, the room, the share of the hour and the watts', () => { + const heating = { on: true, phase: 'heating', set_c: 20, room_c: 19.6, room_source: 'typed', duty: 0.6, duty_hour: 0.55, heat_w: 410, full_w: 410, until_s: 300 }; + assert.deepEqual(V.heatLine(heating), { text: 'Heat mode: holding 20.0 °C · room 19.6 °C · heating 55% of the time · 410 W of heat', tone: 'heat' }); + const resting = { ...heating, phase: 'resting', heat_w: 0, room_source: 'card', room_c: 19.2 }; + assert.deepEqual(V.heatLine(resting), { text: 'Heat mode: holding 20.0 °C · room about 19 °C · heating 55% of the time · resting, 410 W when heating', tone: 'rest' }); + assert.equal(V.heatLine({ on: false }).text, ''); + assert.ok(V.heatLine({ ...heating, room_source: 'none', duty_hour: 0 }).text.includes('no room reading yet · heating 60% of the time')); + assert.equal(V.heatRowWords(heating), 'heat 55% · 410 W of heat'); + assert.equal(V.heatRowWords(resting), 'heat 55% · resting'); + assert.equal(V.heatRowWords({ on: false }), ''); +}); + +test('a resting card and the big button say heat mode, never a bare off', () => { + const r = V.cardRow(card({ state: 'resting', hash_now: 0, message: 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)' })); + assert.equal(r.word, 'resting (heat mode)'); + assert.equal(r.sub, 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)', 'the engine\'s line is the row\'s second line'); + assert.equal(r.hash, ''); + const m = { state: 'waiting', paused: false, cards: [card({ state: 'resting', hash_now: 0 })], found: [] }; + const t = V.toggle(m, { synced: true }, { heat: { on: true, phase: 'resting', set_c: 20, until_s: 240 } }); + assert.equal(t.label, 'Stop mining'); + assert.equal(t.sub, 'resting · heat mode holds 20.0 °C, heats again in 4 min'); + assert.equal(t.act, 'pause'); + assert.deepEqual(V.pill({ setup_done: true, mining: m, node: { state: 'synced' }, heat: { on: true, phase: 'resting' } }), { text: 'Resting · heat mode', tone: '' }); + const e = V.ember(card({ tune_before_watts: 0 }), { settings: { sweep: true }, heat: { on: true, phase: 'heating', duty: 0.5, duty_hour: 0.5, heat_w: 410 } }, 1); + assert.equal(e.heat, 'heat 50% · 410 W of heat'); +}); diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index 43c7283f1..491c1af9f 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -104,7 +104,7 @@
-
step 1 of 2
+
step 1 of 3

Your graphics card

Asking the graphics cards to report in.

@@ -126,10 +126,36 @@
- + +
+
+
step 2 of 3
+

Your electricity

+

Every £ figure in the app comes from the price you type here. Nothing is fetched.

+
+
region
+ +
+
+
price per kWh
+
+ pence per kWh +
+

Choose a region to see a typical price.

+
+ +
+ + + +
+
+
+ +
-
step 2 of 2
+
step 3 of 3

Where should rewards go?

Every block this machine finds pays one address. Pick one way.

@@ -160,7 +186,7 @@
- +
@@ -310,6 +336,7 @@

+
@@ -332,6 +359,7 @@
Type a price£
reading
£0.00 a dayelectricity
+
Your cost of hashagainst the measured rental rate
0 blockslifetime
@@ -441,14 +469,46 @@ +

+
+ +
+

Electricity

typed, never fetched
-
electricity price
+
region
+ +
+
+
price per kWh
- pence per kWh. Every £ figure uses this. + pence per kWh
+

+
+ +
+ +
+

Heat mode

Ember Heat
+ +

+
+
+
hold
+
°C
+
+
+
schedule
+
+
+
+

Blank holds one temperature all day. A schedule is a list of times and temperatures; each holds until the next. Times are this window's clock.

+
+
room now
+
°C from your own thermometer
+

A reading you type is the room for two hours. Without one the card's own sensor reads the room after 3 minutes of rest, within about 3 degrees. Typing a reading while the cards rest teaches the app the card's idle offset.

-

diff --git a/tools/ui-mock/server.mjs b/tools/ui-mock/server.mjs index 0bc7e7ad9..815f36a0c 100644 --- a/tools/ui-mock/server.mjs +++ b/tools/ui-mock/server.mjs @@ -23,6 +23,8 @@ // ladder miner-ui-5: every rung walked (vote, signature, full window, rank 3, streak, a paid shard) // GET /api/ladder answers the chain facts in src/chainfacts.rs's shape (the devnet's params, this key ranked); // POST /api/card accepts the block card's PNG and answers a path. +// 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. import http from 'node:http'; @@ -185,6 +187,15 @@ function stateNow() { if (scenario === 'clock') { s.clock = { ...s.clock, severity: 'block', skew_s: -412, message: 'This machine\'s clock is 6 min 52 s behind the network. Blocks it finds are rejected until the clock is right.', hint: 'System Settings > General > Date & Time: turn on "Set time and date automatically"' }; s.mining.state = 'waiting'; s.mining.hash_total = 0; } if (scenario === 'fresh') { s.setup_done = false; s.phase = 'welcome'; s.address = { display: '', key_saved: false, source: '', value: '', wallet_file: '' }; } if (scenario === 'job') { s.jobs = { ...s.jobs, active: true, id: 'job-2026-10-04-17', kind: 'shard-benchmark', title: 'shard benchmark', stage: 'proving shard 3 of 8', started_at: t - 400, results: ['RESULT shard=1 prove_s=41.2', 'RESULT shard=2 prove_s=39.8'], message: '' }; } + if (scenario === 'heat' || scenario === 'heat-rest') { + const resting = scenario === 'heat-rest'; + 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: resting ? 22 : 290, temp_gpu: resting ? 31 : 64, temp_mem: resting ? 36 : 78, telemetry_at: t, hash_now: resting ? 0 : 122.3, hash_avg: 121.8, state: resting ? 'resting' : 'mining', message: resting ? 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)' : '', 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: t - 7200, sweep_watts: 290, sweep_mhs: 122.3, sweep_eff: 0.422, tune_before_watts: 374, tune_before_mhs: 122.5, tune_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: 2470, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }] }, + { ...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: resting ? 12 : 158, temp_gpu: resting ? 29 : 58, temp_mem: 0, telemetry_at: t, hash_now: resting ? 0 : 61.4, hash_avg: 60.9, state: resting ? 'resting' : 'mining', message: resting ? 'resting: the room is 20.1 °C, holding 20.0 °C (heat mode)' : '', tune_line: 'Tuned: 61.4 MH/s at 158 W (0.389 MH/W)', tune_source: 'confirm', tune_control: true, sweep_pct: 80, sweep_at: t - 3600, sweep_watts: 158, sweep_mhs: 61.4, sweep_eff: 0.389 }]; + s.mining.hash_total = resting ? 0 : 183.7; s.mining.state = resting ? 'waiting' : 'mining'; s.display_name = 'study-pc'; + s.settings = { ...s.settings, sweep: true, power_control: true, tune_goal: 'balanced', power_price_pence: 26.32, tune_climb: false, tune_period_s: 604800, region: 'gb', heat_on: true, heat_set_c: 20, heat_schedule: '06:00 20, 22:00 16', heat_tz_min: 60, heat_room_c: 19.6, heat_room_at: t - 2400, heat_offset_c: 7.5 }; + s.heat = { on: true, phase: resting ? 'resting' : 'heating', set_c: 20, room_c: resting ? 20.1 : 19.6, room_source: 'typed', room_error_c: 0, room_age_s: 2400, duty: 0.6, duty_hour: 0.58, heat_w: resting ? 0 : 448, full_w: 448, heat_avg_w: 269, until_s: resting ? 230 : 312, note: resting ? 'Holding 20.0 °C: room 20.1 °C from your reading 40 min ago. resting, heats again in 4 min. Heat 60% of the time.' : 'Holding 20.0 °C: room 19.6 °C from your reading 40 min ago. heating, 5 min to go. Heat 60% of the time.', period_s: 600, offset_c: 7.5, offset_learned: true }; + s.events = [{ kind: 'info', t: t - 312, text: 'heat mode: heating again' }, { kind: 'info', t: t - 2400, text: 'heat mode on: holding 20.0 °C (schedule 06:00 20, 22:00 16); the cards heat for a share of every 10 minutes and rest for the rest' }].concat(s.events).slice(0, 40); + } 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 },