diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 996ad537..05f82342 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 c9cab06a..ce95d18a 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 00000000..f37554c8 --- /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 43c7283f..491c1af9 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 0bc7e7ad..815f36a0 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 },