Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 85 additions & 2 deletions tests/unit/handlers/finance.handlers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,8 @@ test('processCostsData - handles non-array input', (t) => {

test('calculateSummary - calculates from log entries', (t) => {
const log = [
{ revenueBTC: 0.5, revenueUSD: 20000, totalCostUSD: 5000, profitUSD: 15000, consumptionMWh: 100 },
{ revenueBTC: 0.3, revenueUSD: 12000, totalCostUSD: 3000, profitUSD: 9000, consumptionMWh: 60 }
{ revenueBTC: 0.5, revenueUSD: 20000, energyCostUSD: 4000, totalCostUSD: 5000, profitUSD: 15000, consumptionMWh: 100, sitePowerMW: 4 },
{ revenueBTC: 0.3, revenueUSD: 12000, energyCostUSD: 2400, totalCostUSD: 3000, profitUSD: 9000, consumptionMWh: 60, sitePowerMW: 2 }
]

const summary = calculateSummary(log)
Expand All @@ -244,7 +244,10 @@ test('calculateSummary - calculates from log entries', (t) => {
t.is(summary.totalCostUSD, 8000, 'should sum costs')
t.is(summary.totalProfitUSD, 24000, 'should sum profit')
t.is(summary.totalConsumptionMWh, 160, 'should sum consumption')
t.is(summary.avgPowerConsumption, 3, 'avgPowerConsumption averages sitePowerMW (4+2)/2')
t.ok(summary.avgCostPerMWh !== null, 'should calculate avg cost per MWh')
t.ok(summary.avgEnergyCostPerMWh !== null, 'should calculate avg energy cost per MWh')
t.ok(summary.avgOperationalCostPerMWh !== null, 'should calculate avg operational cost per MWh')
t.ok(summary.avgRevenuePerMWh !== null, 'should calculate avg revenue per MWh')
t.pass()
})
Expand All @@ -257,6 +260,86 @@ test('calculateSummary - handles empty log', (t) => {
t.pass()
})

function makeMockCtx (days) {
return withDataProxy({
conf: { orks: [{ rpcPublicKey: 'key1' }] },
net_r0: {
jRequest: async (_key, method, payload) => {
if (method === 'tailLogCustomRangeAggr') {
return [{
type: 'powermeter',
data: days.map(d => ({ ts: d.ts, val: { site_power_w: d.powerW } })),
error: null
}]
}
if (method === 'getWrkExtData') {
if (payload.query && payload.query.key === 'transactions') {
return days.map(d => ({ ts: d.ts, transactions: [{ ts: d.ts, changed_balance: d.btc }] }))
}
if (payload.query && payload.query.key === 'HISTORICAL_PRICES') {
return days.map(d => ({ ts: d.ts, priceUSD: d.price }))
}
if (payload.query && payload.query.key === 'current_price') {
return [{ currentPrice: days[0].price }]
}
if (payload.query && payload.query.key === 'stats-history') {
return []
}
}
if (method === 'getGlobalConfig') {
return { nominalPowerAvailability_MW: 10 }
}
return {}
}
},
globalDataLib: { getGlobalData: async () => [] }
})
}

test('getEnergyBalance daily - per-day entries carry sitePowerMW and per-MW revenue', async (t) => {
const day1 = Date.UTC(2024, 0, 15)
const day2 = Date.UTC(2024, 0, 16)
const days = [
{ ts: day1, powerW: 5_000_000, btc: 0.5, price: 40000 },
{ ts: day2, powerW: 3_000_000, btc: 0.3, price: 40000 }
]

const result = await getEnergyBalance(makeMockCtx(days), {
query: { start: day1 - 1000, end: day2 + 86400000, period: 'daily' }
}, {})

t.is(result.log.length, 2, 'one entry per day')
for (const e of result.log) {
t.ok(e.sitePowerMW > 0, 'sitePowerMW present')
t.ok('energyRevenueBTC_MW' in e && 'energyRevenueUSD_MW' in e, 'per-MW revenue fields present')
}
t.is(result.log[0].sitePowerMW, 5, 'first day sitePowerMW = 5')
t.is(result.log[1].sitePowerMW, 3, 'second day sitePowerMW = 3')
})

test('getEnergyBalance monthly - rates use MEAN, totals use SUM, per-MW is RECOMPUTED', async (t) => {
const day1 = Date.UTC(2024, 0, 15)
const day2 = Date.UTC(2024, 0, 16)
const day3 = Date.UTC(2024, 0, 17)
const days = [
{ ts: day1, powerW: 5_000_000, btc: 0.5, price: 40000 },
{ ts: day2, powerW: 3_000_000, btc: 0.3, price: 40000 },
{ ts: day3, powerW: 4_000_000, btc: 0.4, price: 40000 }
]

const result = await getEnergyBalance(makeMockCtx(days), {
query: { start: day1 - 1000, end: day3 + 86400000, period: 'monthly' }
}, {})

t.is(result.log.length, 1, 'three days collapse to one monthly bucket')
const m = result.log[0]
t.ok(Math.abs(m.revenueBTC - 1.2) < 1e-9, 'revenueBTC summed (~1.2)')
t.ok(Math.abs(m.revenueUSD - 48000) < 1e-6, 'revenueUSD summed (~48000)')
t.is(m.sitePowerMW, 4, 'sitePowerMW averaged: (5+3+4)/3')
t.ok(Math.abs(m.energyRevenueUSD_MW - 12000) < 1e-6, 'per-MW recomputed from sum / mean, not summed daily values')
t.ok(Math.abs(m.energyRevenueBTC_MW - 0.3) < 1e-9, 'BTC per-MW recomputed')
})

// ==================== EBITDA Tests ====================

test('getEbitda - happy path', async (t) => {
Expand Down
43 changes: 43 additions & 0 deletions tests/unit/lib/period.utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,49 @@ test('aggregateByPeriod - handles invalid timestamps', (t) => {
t.pass()
})

test('aggregateByPeriod - meanKeys option averages instead of summing', (t) => {
const ts = Date.UTC(2024, 0, 15)
const log = [
{ ts, total: 10, rate: 0.1 },
{ ts: ts + 86400000, total: 20, rate: 0.3 }
]
const result = aggregateByPeriod(log, 'monthly', [], { meanKeys: ['rate'] })
t.is(result.length, 1, 'one monthly bucket')
t.is(result[0].total, 30, 'sum keys still summed')
t.is(result[0].rate, 0.2, 'mean key averaged: (0.1+0.3)/2')
})

test('aggregateByPeriod - meanKeys skip null/undefined values when averaging', (t) => {
const ts = Date.UTC(2024, 0, 15)
const log = [
{ ts, rate: 0.1 },
{ ts: ts + 86400000, rate: null },
{ ts: ts + 2 * 86400000, rate: 0.3 }
]
const result = aggregateByPeriod(log, 'monthly', [], { meanKeys: ['rate'] })
t.is(result[0].rate, 0.2, 'null skipped: (0.1+0.3)/2')
})

test('aggregateByPeriod - meanKeys returns null when no entries have the value', (t) => {
const ts = Date.UTC(2024, 0, 15)
const log = [
{ ts, rate: null },
{ ts: ts + 86400000, rate: undefined }
]
const result = aggregateByPeriod(log, 'monthly', [], { meanKeys: ['rate'] })
t.is(result[0].rate, null, 'all-null group yields null')
})

test('aggregateByPeriod - omitting options preserves legacy sum-everything behaviour', (t) => {
const ts = Date.UTC(2024, 0, 15)
const log = [
{ ts, rate: 0.1 },
{ ts: ts + 86400000, rate: 0.3 }
]
const result = aggregateByPeriod(log, 'monthly')
t.is(result[0].rate, 0.4, 'rate is summed when meanKeys not provided')
})

test('getPeriodKey - daily returns start of day', (t) => {
const ts = 1700050000000
const result = getPeriodKey(ts, 'daily')
Expand Down
13 changes: 12 additions & 1 deletion workers/lib/period.utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ const PERIOD_CALCULATORS = {
}
}

const aggregateByPeriod = (log, period, nonMetricKeys = []) => {
const aggregateByPeriod = (log, period, nonMetricKeys = [], options = {}) => {
if (period === PERIOD_TYPES.DAILY) {
return log
}

const allNonMetricKeys = new Set([...NON_METRIC_KEYS, ...nonMetricKeys])
const meanKeys = new Set(options.meanKeys || [])

const grouped = log.reduce((acc, entry) => {
let date
Expand Down Expand Up @@ -68,12 +69,18 @@ const aggregateByPeriod = (log, period, nonMetricKeys = []) => {
}, {})

const aggregatedResults = Object.entries(grouped).map(([groupKey, entries]) => {
const meanCounts = {}
const aggregated = entries.reduce((acc, entry) => {
Object.entries(entry).forEach(([key, val]) => {
if (allNonMetricKeys.has(key)) {
if (!acc[key] || acc[key] === null || acc[key] === undefined) {
acc[key] = val
}
} else if (meanKeys.has(key)) {
if (val !== null && val !== undefined && !isNaN(Number(val))) {
acc[key] = (acc[key] || 0) + Number(val)
meanCounts[key] = (meanCounts[key] || 0) + 1
}
} else {
const numVal = Number(val) || 0
acc[key] = (acc[key] || 0) + numVal
Expand All @@ -82,6 +89,10 @@ const aggregateByPeriod = (log, period, nonMetricKeys = []) => {
return acc
}, {})

for (const key of meanKeys) {
aggregated[key] = meanCounts[key] ? aggregated[key] / meanCounts[key] : null
}

try {
if (period === PERIOD_TYPES.MONTHLY) {
const [year, month] = groupKey.split('-').map(Number)
Expand Down
27 changes: 26 additions & 1 deletion workers/lib/server/handlers/finance.handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ async function getEnergyBalance (ctx, req) {

const powerW = consumption.powerW || 0
const powerMWh = (powerW * 24) / 1000000
const sitePowerMW = powerW / 1000000
const revenueBTC = transactions.revenueBTC || 0
const revenueUSD = revenueBTC * btcPrice

Expand Down Expand Up @@ -133,6 +134,7 @@ async function getEnergyBalance (ctx, req) {
log.push({
ts,
powerW,
sitePowerMW,
consumptionMWh,
revenueBTC,
revenueUSD,
Expand All @@ -149,7 +151,16 @@ async function getEnergyBalance (ctx, req) {
})
}

const aggregated = aggregateByPeriod(log, period)
const aggregated = aggregateByPeriod(log, period, [], {
meanKeys: ['sitePowerMW', 'btcPrice', 'curtailmentRate', 'operationalIssuesRate', 'powerUtilization']
})

for (const entry of aggregated) {
entry.energyRevenueBTC_MW = entry.sitePowerMW > 0 ? entry.revenueBTC / entry.sitePowerMW : 0
entry.energyRevenueUSD_MW = entry.sitePowerMW > 0 ? entry.revenueUSD / entry.sitePowerMW : 0
}
aggregated.sort((a, b) => Number(a.ts) - Number(b.ts))

const summary = calculateSummary(aggregated)

return { log: aggregated, summary }
Expand Down Expand Up @@ -250,7 +261,10 @@ function calculateSummary (log) {
totalCostUSD: 0,
totalProfitUSD: 0,
avgCostPerMWh: null,
avgEnergyCostPerMWh: null,
avgOperationalCostPerMWh: null,
avgRevenuePerMWh: null,
avgPowerConsumption: 0,
totalConsumptionMWh: 0,
avgCurtailmentRate: null,
avgOperationalIssuesRate: null,
Expand All @@ -261,9 +275,14 @@ function calculateSummary (log) {
const totals = log.reduce((acc, entry) => {
acc.revenueBTC += entry.revenueBTC || 0
acc.revenueUSD += entry.revenueUSD || 0
acc.energyCostUSD += entry.energyCostUSD || 0
acc.costUSD += entry.totalCostUSD || 0
acc.profitUSD += entry.profitUSD || 0
acc.consumptionMWh += entry.consumptionMWh || 0
if (entry.sitePowerMW !== null && entry.sitePowerMW !== undefined) {
acc.sitePowerMWSum += entry.sitePowerMW
acc.sitePowerMWCount++
}
if (entry.curtailmentRate !== null && entry.curtailmentRate !== undefined) {
acc.curtailmentRateSum += entry.curtailmentRate
acc.curtailmentRateCount++
Expand All @@ -280,9 +299,12 @@ function calculateSummary (log) {
}, {
revenueBTC: 0,
revenueUSD: 0,
energyCostUSD: 0,
costUSD: 0,
profitUSD: 0,
consumptionMWh: 0,
sitePowerMWSum: 0,
sitePowerMWCount: 0,
curtailmentRateSum: 0,
curtailmentRateCount: 0,
operationalIssuesRateSum: 0,
Expand All @@ -297,7 +319,10 @@ function calculateSummary (log) {
totalCostUSD: totals.costUSD,
totalProfitUSD: totals.profitUSD,
avgCostPerMWh: safeDiv(totals.costUSD, totals.consumptionMWh),
avgEnergyCostPerMWh: safeDiv(totals.energyCostUSD, totals.consumptionMWh),
avgOperationalCostPerMWh: safeDiv(totals.costUSD - totals.energyCostUSD, totals.consumptionMWh),
avgRevenuePerMWh: safeDiv(totals.revenueUSD, totals.consumptionMWh),
avgPowerConsumption: safeDiv(totals.sitePowerMWSum, totals.sitePowerMWCount),
totalConsumptionMWh: totals.consumptionMWh,
avgCurtailmentRate: safeDiv(totals.curtailmentRateSum, totals.curtailmentRateCount),
avgOperationalIssuesRate: safeDiv(totals.operationalIssuesRateSum, totals.operationalIssuesRateCount),
Expand Down
2 changes: 1 addition & 1 deletion workers/lib/server/schemas/finance.schemas.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const schemas = {
properties: {
start: { type: 'integer', minimum: 0 },
end: { type: 'integer', minimum: 0 },
period: { type: 'string', enum: ['daily', 'monthly', 'yearly'] },
period: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'yearly'] },
overwriteCache: { type: 'boolean' }
},
required: ['start', 'end']
Expand Down
Loading