diff --git a/tests/unit/handlers/site.handlers.test.js b/tests/unit/handlers/site.handlers.test.js index 2ef8aa0..e91b9bd 100644 --- a/tests/unit/handlers/site.handlers.test.js +++ b/tests/unit/handlers/site.handlers.test.js @@ -1,7 +1,7 @@ 'use strict' const test = require('brittle') -const { getSiteLiveStatus } = require('../../../workers/lib/server/handlers/site.handlers') +const { getSiteLiveStatus, getSiteOverviewGroupsStats, getSiteEfficiency } = require('../../../workers/lib/server/handlers/site.handlers') const { withDataProxy } = require('../helpers/mockHelpers') function createMockCtx (tailLogMultiResponse, extDataResponse, globalConfigResponse) { @@ -207,3 +207,573 @@ test('getSiteLiveStatus - falls back to globalConfig nominalHashrate', async (t) t.is(result.hashrate.nominal, 2000, 'should fall back to globalConfig nominalHashrate') t.pass() }) + +// ============ getSiteOverviewGroupsStats Tests ============ + +function createMockDcsCtx (tailLogResponse, listThingsResponse) { + return withDataProxy({ + conf: { + orks: [{ rpcPublicKey: 'key1' }], + featureConfig: { centralDCSSetup: { enabled: true, tag: 't-dcs' } } + }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLogMulti') return tailLogResponse + if (method === 'listThings') return listThingsResponse + return {} + } + } + }) +} + +function createMockNoDcsCtx (tailLogResponse) { + return withDataProxy({ + conf: { + orks: [{ rpcPublicKey: 'key1' }], + featureConfig: { centralDCSSetup: { enabled: false } } + }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLogMulti') return tailLogResponse + return {} + } + } + }) +} + +test('getSiteOverviewGroupsStats - returns groups with correct structure', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { 'group-1': 100000000, 'group-2': 200000000 }, + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { 'group-1_rack-1': 50000000, 'group-1_rack-2': 50000000 }, + power_w_container_group_sum_aggr: { 'group-1': 50000, 'group-2': 100000 }, + power_w_pdu_rack_group_sum_aggr: { 'group-1_rack-1': 25000, 'group-1_rack-2': 25000 }, + offline_cnt: { 'group-1': 2, 'group-2': 1 }, + error_cnt: { 'group-1': 1, 'group-2': 0 }, + not_mining_cnt: { 'group-1': 0, 'group-2': 1 }, + power_mode_sleep_cnt: { 'group-1': 5, 'group-2': 3 }, + power_mode_low_cnt: { 'group-1': 10, 'group-2': 8 }, + power_mode_normal_cnt: { 'group-1': 50, 'group-2': 55 }, + power_mode_high_cnt: { 'group-1': 12, 'group-2': 12 } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: { total_groups: 2, racks_per_group: 4, miners_per_rack: 20 }, + energy_layout: { branches: [] } + }, + stats: { dcs_specific: { equipment: { power_meters: [] } } } + } + } + }] + + const ctx = createMockDcsCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteOverviewGroupsStats(ctx, req) + + t.ok(result.groups, 'should have groups') + t.is(result.groups.length, 2, 'should have 2 groups') + + const group1 = result.groups[0] + t.is(group1.id, 'group-1', 'group 1 should have correct id') + t.is(group1.name, 'Group 1', 'group 1 should have correct name') + t.ok(group1.summary, 'group should have summary') + t.ok(group1.summary.hashrate, 'summary should have hashrate') + t.ok(group1.summary.consumption, 'summary should have consumption') + t.ok(group1.summary.efficiency, 'summary should have efficiency') + t.ok(group1.status, 'group should have status') + t.is(group1.status.offline, 2, 'offline count should match') + t.is(group1.status.error, 1, 'error count should match') + t.is(group1.status.sleep, 5, 'sleep count should match') + t.ok(group1.racks, 'group should have racks') + + t.pass() +}) + +test('getSiteOverviewGroupsStats - handles empty miner data gracefully', async (t) => { + // When there's no miner data, groups are determined by what's in hashrateByGroup + // If hashrateByGroup is empty but total_groups is set, it uses total_groups + const tailLogResponse = [ + [{ + // Empty group data - groups will be determined by total_groups config + hashrate_mhs_5m_container_group_sum_aggr: {}, + power_w_container_group_sum_aggr: {}, + power_mode_normal_cnt: {} + }] + ] + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { mining: { total_groups: 2, racks_per_group: 4, miners_per_rack: 20 } }, + stats: { dcs_specific: { equipment: { power_meters: [] } } } + } + } + }] + + const ctx = createMockDcsCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteOverviewGroupsStats(ctx, req) + + t.ok(result.groups, 'should have groups') + t.is(result.groups.length, 2, 'should have groups from total_groups config') + t.is(result.groups[0].status.total, 0, 'total miners should be 0') + t.is(result.groups[0].summary.hashrate.value, 0, 'hashrate should be 0') + t.pass() +}) + +test('getSiteOverviewGroupsStats - works without DCS enabled', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { 'group-1': 100000000 }, + power_w_container_group_sum_aggr: { 'group-1': 50000 }, + power_mode_normal_cnt: { 'group-1': 50 } + }] + ] + + const ctx = createMockNoDcsCtx(tailLogResponse) + const req = { query: {} } + + const result = await getSiteOverviewGroupsStats(ctx, req) + + t.ok(result.groups, 'should have groups') + t.is(result.groups.length, 1, 'should have 1 group from miner data') + t.pass() +}) + +test('getSiteOverviewGroupsStats - builds racks for groups', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { 'group-1': 100000000 }, + hashrate_mhs_5m_pdu_rack_group_avg_aggr: { + 'group-1_rack-1': 30000000, + 'group-1_rack-2': 40000000, + 'group-1_rack-3': 30000000 + }, + power_w_pdu_rack_group_sum_aggr: { + 'group-1_rack-1': 15000, + 'group-1_rack-2': 20000, + 'group-1_rack-3': 15000 + }, + power_mode_normal_cnt: { 'group-1': 60 } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { mining: { total_groups: 1, racks_per_group: 4 } }, + stats: { dcs_specific: { equipment: { power_meters: [] } } } + } + } + }] + + const ctx = createMockDcsCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteOverviewGroupsStats(ctx, req) + + t.is(result.groups[0].racks.length, 3, 'should have 3 racks') + t.is(result.groups[0].racks[0].id, 'rack-1', 'first rack should be rack-1') + t.ok(result.groups[0].racks[0].hashrate.value > 0, 'rack should have hashrate') + t.ok(result.groups[0].racks[0].consumption.value > 0, 'rack should have consumption') + t.pass() +}) + +// ============ getSiteEfficiency Tests ============ + +function createMockEfficiencyCtx (tailLogResponse, listThingsResponse) { + return withDataProxy({ + conf: { + orks: [{ rpcPublicKey: 'key1' }], + featureConfig: { centralDCSSetup: { enabled: true, tag: 't-dcs' } } + }, + net_r0: { + jRequest: async (key, method) => { + if (method === 'tailLogMulti') return tailLogResponse + if (method === 'listThings') return listThingsResponse + return {} + } + } + }) +} + +test('getSiteEfficiency - returns correct structure', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { + 'group-1': 500000000000, + 'group-2': 500000000000, + 'group-3': 500000000000, + 'group-4': 500000000000 + } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: { racks_per_group: 4, miners_per_rack: 20 }, + energy_layout: { + site_meter: 'PM-SITE', + branches: [ + { board: 'DB-1', transformer: 'TX-1', meter: 'PM-1', feeds: 'Groups 1-2' }, + { board: 'DB-2', transformer: 'TX-2', meter: 'PM-2', feeds: 'Groups 3-4' }, + { board: 'DB-CCM', meter: 'PM-CCM', feeds: 'Cooling & Auxiliary' } + ] + } + }, + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-SITE', role: 'site_main', name: 'Site Main', power: { value: 10000, unit: 'kW' } }, + { equipment: 'PM-1', role: 'rack', name: 'Meter 1', power: { value: 4000, unit: 'kW' } }, + { equipment: 'PM-2', role: 'rack', name: 'Meter 2', power: { value: 4000, unit: 'kW' } }, + { equipment: 'PM-CCM', role: 'auxiliary', name: 'CCM', power: { value: 2000, unit: 'kW' } } + ], + distribution_boards: [ + { equipment: 'DB-1', name: 'Distribution Board 1' }, + { equipment: 'DB-2', name: 'Distribution Board 2' } + ], + transformers: [ + { equipment: 'TX-1', name: 'Transformer 1' }, + { equipment: 'TX-2', name: 'Transformer 2' } + ] + } + } + } + } + } + }] + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteEfficiency(ctx, req) + + t.ok(result.summary, 'should have summary') + t.ok(result.summary.site_efficiency, 'should have site_efficiency') + t.ok(result.summary.mining_efficiency, 'should have mining_efficiency') + t.ok(result.summary.total_consumption, 'should have total_consumption') + t.ok(result.summary.ca_overhead, 'should have ca_overhead') + t.is(result.summary.total_consumption.unit, 'MW', 'total_consumption unit should be MW') + + t.ok(result.efficiency_per_meter, 'should have efficiency_per_meter') + t.ok(Array.isArray(result.efficiency_per_meter), 'efficiency_per_meter should be array') + t.is(result.efficiency_per_meter.length, 2, 'should have 2 rack meters') + + t.ok(result.consumption_breakdown, 'should have consumption_breakdown') + t.ok(Array.isArray(result.consumption_breakdown), 'consumption_breakdown should be array') + + t.pass() +}) + +test('getSiteEfficiency - computes efficiency correctly', async (t) => { + // 1 TH/s = 1,000,000 MH/s, so 2 TH/s total + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { + 'group-1': 1000000, + 'group-2': 1000000 + } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: { racks_per_group: 4, miners_per_rack: 20 }, + energy_layout: { + site_meter: 'PM-SITE', + branches: [ + { board: 'DB-1', transformer: 'TX-1', meter: 'PM-1', feeds: 'Groups 1-2' } + ] + } + }, + stats: { + dcs_specific: { + equipment: { + power_meters: [ + // Site uses 100 kW, mining uses 80 kW for 2 TH/s = 40 W/TH + { equipment: 'PM-SITE', role: 'site_main', power: { value: 100, unit: 'kW' } }, + { equipment: 'PM-1', role: 'rack', power: { value: 80, unit: 'kW' } } + ], + distribution_boards: [], + transformers: [] + } + } + } + } + } + }] + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteEfficiency(ctx, req) + + // Mining efficiency: 80 kW * 1000 / 2 TH/s = 40000 W/TH + t.is(result.summary.mining_efficiency.value, 40000, 'mining efficiency should be 40000 W/TH') + // Site efficiency: 100 kW * 1000 / 2 TH/s = 50000 W/TH + t.is(result.summary.site_efficiency.value, 50000, 'site efficiency should be 50000 W/TH') + t.pass() +}) + +test('getSiteEfficiency - computes C&A overhead', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { 'group-1': 1000000 } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: {}, + energy_layout: { + site_meter: 'PM-SITE', + branches: [ + { board: 'DB-1', meter: 'PM-1', feeds: 'Groups 1-2' }, + { board: 'DB-CCM', meter: 'PM-CCM', feeds: 'Cooling' } + ] + } + }, + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-SITE', role: 'site_main', power: { value: 1000, unit: 'kW' } }, + { equipment: 'PM-1', role: 'rack', power: { value: 800, unit: 'kW' } }, + { equipment: 'PM-CCM', role: 'auxiliary', power: { value: 200, unit: 'kW' } } + ], + distribution_boards: [], + transformers: [] + } + } + } + } + } + }] + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteEfficiency(ctx, req) + + // C&A overhead: 200 / 1000 * 100 = 20% + t.is(result.summary.ca_overhead.value, 20, 'C&A overhead should be 20%') + t.is(result.summary.ca_overhead.unit, '%', 'C&A overhead unit should be %') + t.pass() +}) + +test('getSiteEfficiency - throws error when DCS data not found', async (t) => { + const tailLogResponse = [[{ hashrate_mhs_5m_container_group_sum_aggr: {} }]] + const listThingsResponse = [] // No DCS thing + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + try { + await getSiteEfficiency(ctx, req) + t.fail('should have thrown error') + } catch (err) { + t.is(err.message, 'ERR_DCS_DATA_NOT_FOUND', 'should throw ERR_DCS_DATA_NOT_FOUND') + } + t.pass() +}) + +test('getSiteEfficiency - handles zero hashrate gracefully', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { 'group-1': 0, 'group-2': 0 } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: {}, + energy_layout: { + site_meter: 'PM-SITE', + branches: [ + { board: 'DB-1', meter: 'PM-1', feeds: 'Groups 1-2' } + ] + } + }, + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-SITE', role: 'site_main', power: { value: 1000, unit: 'kW' } }, + { equipment: 'PM-1', role: 'rack', power: { value: 800, unit: 'kW' } } + ], + distribution_boards: [], + transformers: [] + } + } + } + } + } + }] + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteEfficiency(ctx, req) + + t.is(result.summary.site_efficiency.value, 0, 'site efficiency should be 0 when hashrate is 0') + t.is(result.summary.mining_efficiency.value, 0, 'mining efficiency should be 0 when hashrate is 0') + t.pass() +}) + +test('getSiteEfficiency - builds consumption breakdown', async (t) => { + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { 'group-1': 1000000 } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: {}, + energy_layout: { + site_meter: 'PM-SITE', + branches: [ + { board: 'DB-1', meter: 'PM-1', feeds: 'Groups 1-2' }, + { board: 'DB-2', meter: 'PM-2', feeds: 'Groups 3-4' } + ] + } + }, + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-SITE', role: 'site_main', name: 'Site Main', power: { value: 1000, unit: 'kW' } }, + { equipment: 'PM-1', role: 'rack', power: { value: 600, unit: 'kW' } }, + { equipment: 'PM-2', role: 'rack', power: { value: 400, unit: 'kW' } } + ], + distribution_boards: [], + transformers: [] + } + } + } + } + } + }] + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteEfficiency(ctx, req) + + t.ok(result.consumption_breakdown.length >= 3, 'should have at least 3 entries in breakdown') + + const siteEntry = result.consumption_breakdown.find(e => e.source === 'Site Main') + t.ok(siteEntry, 'should have site main entry') + t.is(siteEntry.percent, 100, 'site main should be 100%') + + const db1Entry = result.consumption_breakdown.find(e => e.source === 'DB-1') + t.ok(db1Entry, 'should have DB-1 entry') + t.is(db1Entry.percent, 60, 'DB-1 should be 60%') + + const db2Entry = result.consumption_breakdown.find(e => e.source === 'DB-2') + t.ok(db2Entry, 'should have DB-2 entry') + t.is(db2Entry.percent, 40, 'DB-2 should be 40%') + + t.pass() +}) + +test('getSiteEfficiency - handles efficiency per meter correctly', async (t) => { + // 1000 TH/s per group = 1,000,000,000 MH/s + const tailLogResponse = [ + [{ + hashrate_mhs_5m_container_group_sum_aggr: { + 'group-1': 1000000000000, // 1000 TH/s + 'group-2': 1000000000000 // 1000 TH/s + } + }] + ] + + const listThingsResponse = [{ + id: 'dcs-1', + type: 'dcs-central', + tags: ['t-dcs'], + last: { + snap: { + config: { + mining: { racks_per_group: 4, miners_per_rack: 20 }, + energy_layout: { + site_meter: 'PM-SITE', + branches: [ + { board: 'DB-1', transformer: 'TX-1', meter: 'PM-1', feeds: 'Groups 1-2' } + ] + } + }, + stats: { + dcs_specific: { + equipment: { + power_meters: [ + { equipment: 'PM-SITE', role: 'site_main', power: { value: 50000, unit: 'kW' } }, + // 40000 kW for 2000 TH/s = 20 W/TH + { equipment: 'PM-1', role: 'rack', power: { value: 40000, unit: 'kW' } } + ], + distribution_boards: [{ equipment: 'DB-1', name: 'Board 1' }], + transformers: [{ equipment: 'TX-1', name: 'Transformer 1' }] + } + } + } + } + } + }] + + const ctx = createMockEfficiencyCtx(tailLogResponse, listThingsResponse) + const req = { query: {} } + + const result = await getSiteEfficiency(ctx, req) + + t.is(result.efficiency_per_meter.length, 1, 'should have 1 meter entry') + const meter = result.efficiency_per_meter[0] + t.is(meter.board, 'DB-1', 'board should be DB-1') + t.is(meter.board_name, 'Board 1', 'board_name should be Board 1') + t.is(meter.transformer, 'TX-1', 'transformer should be TX-1') + t.is(meter.transformer_name, 'Transformer 1', 'transformer_name should be Transformer 1') + t.is(meter.feeds, 'Groups 1-2', 'feeds should match') + t.is(meter.efficiency.value, 20, 'efficiency should be 20 W/TH') + t.is(meter.miners, 160, 'miners should be 2 groups * 4 racks * 20 miners = 160') + t.pass() +}) diff --git a/tests/unit/routes/site.routes.test.js b/tests/unit/routes/site.routes.test.js index 7b67dbc..8b08845 100644 --- a/tests/unit/routes/site.routes.test.js +++ b/tests/unit/routes/site.routes.test.js @@ -15,6 +15,7 @@ test('site routes - route definitions', (t) => { const routeUrls = routes.map(route => route.url) t.ok(routeUrls.includes('/auth/site/status/live'), 'should have site status live route') t.ok(routeUrls.includes('/auth/site/overview/groups'), 'should have site overview groups route') + t.ok(routeUrls.includes('/auth/site/efficiency'), 'should have site efficiency route') t.pass() }) @@ -28,6 +29,9 @@ test('site routes - HTTP methods', (t) => { const siteOverviewRoute = routes.find(r => r.url === '/auth/site/overview/groups') t.is(siteOverviewRoute.method, 'GET', 'site overview groups route should be GET') + const siteEfficiencyRoute = routes.find(r => r.url === '/auth/site/efficiency') + t.is(siteEfficiencyRoute.method, 'GET', 'site efficiency route should be GET') + t.pass() }) @@ -44,6 +48,11 @@ test('site routes - schema validation', (t) => { t.ok(siteOverviewRoute.schema.querystring, 'should have querystring schema') t.is(siteOverviewRoute.schema.querystring.properties.overwriteCache.type, 'boolean', 'overwriteCache should be boolean') + const siteEfficiencyRoute = routes.find(r => r.url === '/auth/site/efficiency') + t.ok(siteEfficiencyRoute.schema, 'site efficiency route should have schema') + t.ok(siteEfficiencyRoute.schema.querystring, 'should have querystring schema') + t.is(siteEfficiencyRoute.schema.querystring.properties.overwriteCache.type, 'boolean', 'overwriteCache should be boolean') + t.pass() }) diff --git a/workers/lib/constants.js b/workers/lib/constants.js index 23f1c58..bb22b51 100644 --- a/workers/lib/constants.js +++ b/workers/lib/constants.js @@ -163,7 +163,9 @@ const ENDPOINTS = { // Energy System endpoints ENERGY_SYSTEM: '/auth/dcs/energy-system', // Site Overview endpoints - SITE_OVERVIEW_GROUPS: '/auth/site/overview/groups' + SITE_OVERVIEW_GROUPS: '/auth/site/overview/groups', + // Site Efficiency endpoint + SITE_EFFICIENCY: '/auth/site/efficiency' } const HTTP_METHODS = { @@ -436,7 +438,7 @@ const SITE_OVERVIEW_AGGR_FIELDS = { power_w_rack_group_sum_aggr: 1, efficiency_w_ths_container_group_avg_aggr: 1, efficiency_w_ths_pdu_rack_group_avg_aggr: 1, - hashrate_mhs_5m_pdu_rack_group_avg_aggr:1, + hashrate_mhs_5m_pdu_rack_group_avg_aggr: 1, power_w_pdu_rack_group_sum_aggr: 1, offline_cnt: 1, error_cnt: 1, @@ -455,6 +457,15 @@ const DCS_POWER_METER_FIELDS = { 'last.snap.config.energy_layout': 1 } +// DCS field projections for site efficiency +const DCS_EFFICIENCY_FIELDS = { + 'last.snap.stats.dcs_specific.equipment.power_meters': 1, + 'last.snap.stats.dcs_specific.equipment.distribution_boards': 1, + 'last.snap.stats.dcs_specific.equipment.transformers': 1, + 'last.snap.config.mining': 1, + 'last.snap.config.energy_layout': 1 +} + const AGGR_FIELDS = { HASHRATE_SUM: 'hashrate_mhs_5m_sum_aggr', SITE_POWER: 'site_power_w', @@ -654,5 +665,6 @@ module.exports = { COOLING_SYSTEM_PROJECTIONS, ENERGY_SYSTEM_PROJECTIONS, SITE_OVERVIEW_AGGR_FIELDS, - DCS_POWER_METER_FIELDS + DCS_POWER_METER_FIELDS, + DCS_EFFICIENCY_FIELDS } diff --git a/workers/lib/server/handlers/site.handlers.js b/workers/lib/server/handlers/site.handlers.js index d895b59..20ea677 100644 --- a/workers/lib/server/handlers/site.handlers.js +++ b/workers/lib/server/handlers/site.handlers.js @@ -14,7 +14,8 @@ const { WORKER_TYPES, WORKER_TAGS, SITE_OVERVIEW_AGGR_FIELDS, - DCS_POWER_METER_FIELDS + DCS_POWER_METER_FIELDS, + DCS_EFFICIENCY_FIELDS } = require('../../constants') const { isCentralDCSEnabled, @@ -312,8 +313,6 @@ function aggregateOverviewMinerStats (tailLogResults) { const entry = extractKeyEntry(orkResult, 0) if (!entry) continue - console.log("entry", entry) - mergeGroupedField(aggregated.hashrateByGroup, entry.hashrate_mhs_5m_container_group_sum_aggr) mergeGroupedField(aggregated.hashrateByRack, entry.hashrate_mhs_5m_pdu_rack_group_avg_aggr) mergeGroupedField(aggregated.powerByGroup, entry.power_w_container_group_sum_aggr) @@ -487,7 +486,169 @@ async function getSiteOverviewGroupsStats (ctx, req) { return composeGroupsStats(minerStats, dcsThing, totalGroups) } +function composeSiteEfficiency (minerStats, dcsThing) { + const config = dcsThing?.last?.snap?.config || {} + const miningConfig = config.mining || {} + const energyLayout = config.energy_layout || {} + const powerMeters = dcsThing?.last?.snap?.stats?.dcs_specific?.equipment?.power_meters || [] + const distributionBoards = dcsThing?.last?.snap?.stats?.dcs_specific?.equipment?.distribution_boards || [] + const transformers = dcsThing?.last?.snap?.stats?.dcs_specific?.equipment?.transformers || [] + const branches = energyLayout.branches || [] + + const minersPerGroup = (miningConfig.racks_per_group || 4) * (miningConfig.miners_per_rack || 20) + const siteMeter = powerMeters.find(pm => pm.equipment === energyLayout.site_meter) || powerMeters.find(pm => pm.role === 'site_main') + const siteTotalKw = siteMeter?.power?.value || 0 + const powerUnit = siteMeter?.power?.unit + + const efficiencyPerMeter = [] + let totalMiningPowerKw = 0 + let totalHashrateMhs = 0 + + for (const branch of branches) { + const meter = powerMeters.find(pm => pm.equipment === branch.meter) + if (!meter || meter.role !== 'rack') continue + + const meterPower = meter.power?.value || 0 + const coveredGroups = [] + const feedsMatch = branch.feeds?.match(/Groups?\s+(\d+)-(\d+)/i) + if (feedsMatch) { + const start = parseInt(feedsMatch[1], 10) + const end = parseInt(feedsMatch[2], 10) + for (let i = start; i <= end; i++) { + coveredGroups.push(`group-${i}`) + } + } + + let branchHashrateMhs = 0 + for (const groupName of coveredGroups) { + branchHashrateMhs += minerStats.hashrateByGroup[groupName] || 0 + } + + const branchHashrateThs = mhsToThs(branchHashrateMhs) + const efficiency = branchHashrateThs > 0 + ? Math.round(((meterPower * 1000) / branchHashrateThs) * 100) / 100 + : 0 + + totalMiningPowerKw += meterPower + totalHashrateMhs += branchHashrateMhs + + const board = distributionBoards.find(db => db.equipment === branch.board) + const transformer = transformers.find(tr => tr.equipment === branch.transformer) + + efficiencyPerMeter.push({ + board: branch.board, + board_name: board?.name || branch.board, + transformer: branch.transformer, + transformer_name: transformer?.name || branch.transformer, + feeds: branch.feeds, + meter: branch.meter, + efficiency: { value: efficiency, unit: 'W/THs' }, + power: { value: Math.round(meterPower * 10) / 10, unit: powerUnit }, + hashrate: { value: mhsToPhs(branchHashrateMhs), unit: 'PH/s' }, + miners: coveredGroups.length * minersPerGroup + }) + } + + // Site-level efficiency + const totalHashrateThs = mhsToThs(totalHashrateMhs) + const siteEfficiency = totalHashrateThs > 0 + ? Math.round(((siteTotalKw * 1000) / totalHashrateThs) * 100) / 100 + : 0 + const miningEfficiency = totalHashrateThs > 0 + ? Math.round(((totalMiningPowerKw * 1000) / totalHashrateThs) * 100) / 100 + : 0 + + // C&A overhead + const ccmBranch = branches.find(b => b.feeds && !b.feeds.match(/Groups?\s+/i)) + const ccmMeter = ccmBranch ? powerMeters.find(pm => pm.equipment === ccmBranch.meter) : null + const ccmPowerKw = ccmMeter?.power?.value || 0 + const caOverhead = siteTotalKw > 0 + ? Math.round((ccmPowerKw / siteTotalKw) * 1000) / 10 + : 0 + + // Consumption breakdown + const consumptionBreakdown = [] + + if (siteMeter) { + consumptionBreakdown.push({ + source: siteMeter.name || siteMeter.equipment, + board: null, + meter: siteMeter.equipment, + consumption: { value: Math.round(siteTotalKw * 10) / 10, unit: powerUnit }, + percent: 100 + }) + } + + for (const branch of branches) { + const meter = powerMeters.find(pm => pm.equipment === branch.meter) + if (!meter) continue + const meterPower = meter.power?.value || 0 + consumptionBreakdown.push({ + source: branch.board, + board: branch.board, + feeds: branch.feeds, + meter: branch.meter, + consumption: { value: Math.round(meterPower * 10) / 10, unit: powerUnit }, + percent: siteTotalKw > 0 ? Math.round((meterPower / siteTotalKw) * 1000) / 10 : 0 + }) + } + + return { + summary: { + site_efficiency: { value: siteEfficiency, unit: 'W/THs' }, + mining_efficiency: { value: miningEfficiency, unit: 'W/THs' }, + total_consumption: { value: Math.round((siteTotalKw / 1000) * 1000) / 1000, unit: 'MW' }, + ca_overhead: { value: caOverhead, unit: '%' } + }, + efficiency_per_meter: efficiencyPerMeter, + consumption_breakdown: consumptionBreakdown + } +} + +/** + * GET /auth/site/efficiency + * + * Returns site efficiency metrics combining: + * - Miner hashrate stats from tailLog (per group) + * - DCS power meters, distribution boards, transformers for branch-level power + */ +async function getSiteEfficiency (ctx, req) { + const tailLogPayload = { + keys: [ + { key: LOG_KEYS.STAT_RTD, type: WORKER_TYPES.MINER, tag: WORKER_TAGS.MINER } + ], + limit: 1, + aggrFields: SITE_OVERVIEW_AGGR_FIELDS + } + + const dcsEnabled = isCentralDCSEnabled(ctx) + let dcsPayload = null + if (dcsEnabled) { + const dcsTag = getDCSTag(ctx) + dcsPayload = { + query: { tags: { $in: [dcsTag] } }, + status: 1, + fields: { id: 1, code: 1, type: 1, tags: 1, ...DCS_EFFICIENCY_FIELDS } + } + } + + const [tailLogResults, dcsResults] = await Promise.all([ + ctx.dataProxy.requestDataMap('tailLogMulti', tailLogPayload), + dcsEnabled ? ctx.dataProxy.requestDataMap('listThings', dcsPayload) : Promise.resolve(null) + ]) + + const minerStats = aggregateOverviewMinerStats(tailLogResults) + const dcsThing = dcsResults ? extractDcsThing(dcsResults) : null + + if (!dcsThing) { + throw new Error('ERR_DCS_DATA_NOT_FOUND') + } + + return composeSiteEfficiency(minerStats, dcsThing) +} + module.exports = { getSiteLiveStatus, - getSiteOverviewGroupsStats + getSiteOverviewGroupsStats, + getSiteEfficiency } diff --git a/workers/lib/server/routes/site.routes.js b/workers/lib/server/routes/site.routes.js index 8154688..a26ea3a 100644 --- a/workers/lib/server/routes/site.routes.js +++ b/workers/lib/server/routes/site.routes.js @@ -6,7 +6,7 @@ const { AUTH_CAPS, AUTH_LEVELS } = require('../../constants') -const { getSiteLiveStatus, getSiteOverviewGroupsStats } = require('../handlers/site.handlers') +const { getSiteLiveStatus, getSiteOverviewGroupsStats, getSiteEfficiency } = require('../handlers/site.handlers') const { createCachedAuthRoute } = require('../lib/routeHelpers') module.exports = (ctx) => [ @@ -46,5 +46,24 @@ module.exports = (ctx) => [ getSiteOverviewGroupsStats, [`${AUTH_CAPS.m}:${AUTH_LEVELS.READ}`] ) + }, + { + method: HTTP_METHODS.GET, + url: ENDPOINTS.SITE_EFFICIENCY, + schema: { + querystring: { + type: 'object', + properties: { + overwriteCache: { type: 'boolean' } + } + } + }, + ...createCachedAuthRoute( + ctx, + ['site-efficiency'], + ENDPOINTS.SITE_EFFICIENCY, + getSiteEfficiency, + [`${AUTH_CAPS.m}:${AUTH_LEVELS.READ}`] + ) } ]