The /fleet dashboard totals are not a fork of the math — they are the same per-rig aggregator, summed element-wise first across rigs in a site, then across sites in the fleet. Change the math in one place and the head-line and the export move with it. This post walks the implementation end to end and then drives a worked example through it, so the six columns in the dashboard row are not a black box but a chain of explicit calls.
What /fleet shows, in six columns
Each column maps 1:1 onto a numeric field on the per-rig aggregator output (`LedgerItem` / `LedgerTotals` in `@/lib/contracts/ledger`). The dashboard renders the FleetPnlList envelope the same way the per-rig ledger page renders the LedgerList envelope.
| Dashboard column | LedgerItem field | Formula |
|---|---|---|
| TH-days | thDays | Σ (hashrateThs × segment_hours) ÷ 24 |
| Uptime (h) | uptimeHours | Σ segment_hours |
| Est. kWh | estKwh | uptime × drawWatts ÷ 1000 (one slab at a time) |
| Energy cost | estEnergyCostUsd | Σ (kwh × midpoint_price) over slabs |
| Est. revenue | estRevenueUsd | thDays × poolRate × BTC_USD × (1 − poolFee) |
| Net margin | netMarginUsd | revenue − energy cost |
The slab walk
Telemetry readings are point-in-time samples, not continuous. The aggregator walks the gap between every adjacent pair of readings as one carry-forward segment, then breaks each segment into 1-hour UTC slabs for cost attribution. Each slab carries the LATER reading's hashrate (the carry-forward model — readings report the state held since the prior tick). The pre-first gap (before any reading) and the post-last gap (after the last reading) are not counted as uptime: an audit ledger cannot infer power draw for unobserved hours.
- readings sorted by ascending timestamp
- each adjacent pair yields one segment [ts_i, ts_{i+1}) with hashrate from the later reading
- segments are not anchored at rangeStart or rangeEnd — those gaps are silent
- segments are walked in 1-hour UTC slabs, clipped to [rangeStart, rangeEnd]
- each slab's UTC-day attribution is the UTC-date key of its midpoint
- each slab's price is read at its midpoint (see priceAtMidpoint below)
Range anchor: rangeStart = now − days × 86_400_000, rangeEnd = now. The aggregator pads one row per UTC day whose key intersects this range, so the on-screen count depends on now's time-of-day epoch — days = 7 at the top of a UTC hour yields 7 rows; mid-day it can yield 8.
priceAtMidpoint, in tz-aware detail
The mid-slab kWh figure is multiplied by the per-operator $/kWh figure through priceAtMidpoint. The window lookup converts the slab midpoint's UTC timestamp into the site's operator-tz local clock (e.g. America/Chicago vs America/New_York): it reads the local year, month, day, and 24-hour `hh` directly from Intl.DateTimeFormat output. Day-of-week is computed using noon local — `new Date(Date.UTC(y, m − 1, d, 12)).getUTCDay()` — to avoid a TZ-day flop right at local midnight, where a tiny UTC drift would otherwise carry the slab into the previous weekday's window.
If no window in the site's SitePowerWindow rows matches the local day-of-week + hour, the function returns defaultPrice. That default is the env-driven `LEDGER_DEFAULT_PRICE_USD_PER_KWH` (default 0.10 — i.e. $0.10/kWh) so a site with zero windows configured charges uniformly rather than diverging silently.
mostSeenPool
Pool attribution is per UTC day, not per total. Each reading increments a counter for its pool name in the bucket for its UTC-day key. The function mostSeenPool returns the name of the highest count, falling back to null when the day had zero readings. A day that switched pools mid-window reports whichever pool dominated by reading count — the per-rig CSV stamps exactly that string into the row's `pool` column, and an operator scanning the export can see at a glance whose fees drove the day.
Worked example — one rig, one UTC day
One Antminer S21 at a single site in America/Chicago, reporting five readings at UTC 00:00, 06:00, 12:00, 18:00, and the following day's 00:00. Hashrates 95, 100, 105, 100, 95 TH/s, all on pool A. The site has one M–F off-peak window 22:00–24:00 local at $0.04/kWh; every other operator-hour falls back to the LEDGER_DEFAULT_PRICE_USD_PER_KWH default of $0.10/kWh.
Five readings produce four carry-forward segments, each 6 hours long, with the later reading's hashrate filling the gap:
- Segment 1 — UTC 00:00–06:00, hashrate 100 TH/s (later reading's)
- Segment 2 — UTC 06:00–12:00, hashrate 105 TH/s
- Segment 3 — UTC 12:00–18:00, hashrate 100 TH/s
- Segment 4 — UTC 18:00–24:00, hashrate 95 TH/s
Each segment is unwrapped into six 1-hour UTC slabs. The slab midpoint drives the priceAtMidpoint lookup:
- Slab midpoint UTC 03:00 — Chicago local Mon 22:00 — in off-peak window → $0.04
- Slab midpoint UTC 09:00 — Chicago local Tue 04:00 — no window match → $0.10 default
- Slab midpoint UTC 15:00 — Chicago local Tue 10:00 — no window match → $0.10 default
- Slab midpoint UTC 21:00 — Chicago local Tue 16:00 — no window match → $0.10 default
Slab cost collapses segment-wise:
- Segment 1 — 21 kWh × $0.04 = $0.84
- Segment 2 — 21 kWh × $0.10 = $2.10
- Segment 3 — 21 kWh × $0.10 = $2.10
- Segment 4 — 21 kWh × $0.10 = $2.10
- Total energy cost = $7.14
The row that lands in /fleet
aggregateLedger returns one LedgerItem for this UTC day plus a totals row. The /fleet dashboard renders exactly these six columns:
| Date | TH-days | Uptime (h) | Est. kWh | Energy cost | Est. revenue | Net margin |
|---|---|---|---|---|---|---|
| 2026-08-04 | 100 | 24.0 | 84 | $7.14 | $235.20 | $228.06 |
Revenue walks as: 100 TH-days × 0.00004 BTC/TH·day × $60,000/BTC × (1 − 0.02) = 0.004 BTC × $60,000 × 0.98 = $235.20. Net margin = $235.20 − $7.14 = $228.06. An operator auditing this row hears the same numbers read back from the ledger CSV — they are computed by the same function with the same inputs.
Where /fleet composes
The fleet view is element-wise summation, not a parallel computation. computeFleetPnl in src/lib/business/fleet-pnl.ts calls aggregateLedger once per rig — never more, never less — accumulates each rig's LedgerTotals into a per-site total via sumTotals, then re-sums those site totals into the fleet headline. Both sumTotals calls keep the same audit precision the per-rig aggregator uses (cents on USD, six decimal places on hours/TH/kWh), so the cross-rig total agrees with the per-rig number to the cent.
Outside that loop, the fleet path duplicates nothing about the math. The slab walker, the tz-aware price model, the pool-bandwidth lookup, the BTC × (1 − poolFee) revenue model all live in `aggregateLedger`. A change to the per-rig aggregator propagates to /fleet without a fork — that is the point of the file layout, and the headline number and the export number cannot drift apart.
Takeaway
The /fleet total is the sum. The export is the sum. The CSV envelope and the on-screen headline run through the same per-rig call, so an operator who wants to verify a number on the dashboard against the ledger download can do so line-for-line. If you want to confirm the worked numbers above against the live aggregator, the dashboard sits one click away — see the figures on /fleet for any rig on the platform and the math above plays out row-for-row against that rig's own readings, draw spec, and site windows.
Sources
- aggregateLedger — src/lib/business/rig-ledger.ts (per-rig aggregator; slab walker, priceAtMidpoint, mostSeenPool)
- computeFleetPnl — src/lib/business/fleet-pnl.ts (per-rig calls + element-wise summation across sites)
- CoinDesk BTC/USD index — coindesk.com/indices (the upstream feed that anchors LEDGER_BTC_PRICE_USD once configured)
- mempool.space live stats — mempool.space (network hashrate / difficulty / block template; the upstream anchor for the pool hashprice math used in the inaugural pool-switching post)