From cd46f4783369f06a7b16f1055b6b525d45b12e08 Mon Sep 17 00:00:00 2001 From: o-webdev Date: Sat, 28 Mar 2026 23:26:11 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v0.7.0=20=E2=80=94=20memory=20power=20d?= =?UTF-8?q?raw=20(CPU=20+=20memory=20watts=20per=20CCF=20standard),=203=20?= =?UTF-8?q?new=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- METHODOLOGY.md | 34 ++++-- dist/index.cjs | 20 ++-- engine.test.ts | 220 +++++++++++++++++++++++------------- engine.ts | 26 ++++- formatters/markdown.test.ts | 1 + formatters/table.test.ts | 1 + integration.test.ts | 63 +++++------ package.json | 2 +- types.ts | 2 + 9 files changed, 234 insertions(+), 135 deletions(-) diff --git a/METHODOLOGY.md b/METHODOLOGY.md index 38b9bb1..1252cc6 100644 --- a/METHODOLOGY.md +++ b/METHODOLOGY.md @@ -34,16 +34,22 @@ Run `greenops-cli --coverage` to see the full instance and region list per provi ### Power Model -GreenOps uses the **linear interpolation model** from the Cloud Carbon Footprint (CCF) methodology: +GreenOps uses the **linear interpolation model** from the Cloud Carbon Footprint (CCF) methodology, extended with memory power draw: ``` -W_effective = W_idle + (W_max - W_idle) × utilization +W_cpu = W_idle + (W_max - W_idle) × utilization +W_memory = memory_gb × 0.392 W/GB +W_effective = W_cpu + W_memory ``` Where: - `W_idle` = idle TDP (watts) from `factors.json` - `W_max` = maximum TDP (watts) from `factors.json` - `utilization` = CPU utilisation fraction (default: 0.50, matching CCF baseline) +- `memory_gb` = RAM size from `factors.json` +- `0.392 W/GB` = CCF memory power coefficient (constant, not utilization-dependent) + +Memory power draw is **constant** regardless of CPU utilisation. This reflects that DRAM draws near-constant power whether or not it is actively being written to, consistent with CCF v3 methodology. ### Carbon Calculation @@ -64,21 +70,27 @@ GCP's 1.10 PUE is the best in class among the three major providers, producing ~ ### Worked Example — AWS m5.large in us-east-1 at 50% utilisation -1. **Power:** `W = 6.8 + (20.4 - 6.8) × 0.50 = 13.6W` -2. **Energy:** `13.6W × 1.13 PUE × 730h / 1000 = 11.219 kWh/month` -3. **Carbon:** `11.219 × 384.5 = 4,313.6g CO2e/month` +1. **CPU power:** `W_cpu = 6.8 + (20.4 - 6.8) × 0.50 = 13.6W` +2. **Memory power:** `W_mem = 8GB × 0.392 = 3.136W` +3. **Total:** `W = 13.6 + 3.136 = 16.736W` +4. **Energy:** `16.736W × 1.13 PUE × 730h / 1000 = 13.816 kWh/month` +5. **Carbon:** `13.816 × 384.5 = 5,308.2g CO2e/month` ### Worked Example — Azure Standard_D2s_v3 in eastus at 50% utilisation -1. **Power:** `W = 6.8 + (20.4 - 6.8) × 0.50 = 13.6W` -2. **Energy:** `13.6W × 1.125 PUE × 730h / 1000 = 11.178 kWh/month` -3. **Carbon:** `11.178 × 380.0 = 4,244.2g CO2e/month` +1. **CPU power:** `W_cpu = 6.8 + (20.4 - 6.8) × 0.50 = 13.6W` +2. **Memory power:** `W_mem = 8GB × 0.392 = 3.136W` +3. **Total:** `W = 16.736W` +4. **Energy:** `16.736W × 1.125 PUE × 730h / 1000 = 13.745 kWh/month` +5. **Carbon:** `13.745 × 380.0 = 5,222.9g CO2e/month` ### Worked Example — GCP n2-standard-2 in us-central1 at 50% utilisation -1. **Power:** `W = 6.8 + (20.4 - 6.8) × 0.50 = 13.6W` -2. **Energy:** `13.6W × 1.10 PUE × 730h / 1000 = 10.921 kWh/month` -3. **Carbon:** `10.921 × 340.0 = 3,713.1g CO2e/month` +1. **CPU power:** `W_cpu = 6.8 + (20.4 - 6.8) × 0.50 = 13.6W` +2. **Memory power:** `W_mem = 8GB × 0.392 = 3.136W` +3. **Total:** `W = 16.736W` +4. **Energy:** `16.736W × 1.10 PUE × 730h / 1000 = 13.445 kWh/month` +5. **Carbon:** `13.445 × 340.0 = 4,569.3g CO2e/month` --- diff --git a/dist/index.cjs b/dist/index.cjs index b10a67e..3689564 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -1968,7 +1968,7 @@ var factors_default = { // package.json var package_default = { name: "greenops-cli", - version: "0.6.0", + version: "0.7.0", description: "Carbon footprint linting for Terraform plans \u2014 AWS, Azure, and GCP. Analyses infrastructure changes for Scope 2, Scope 3, and water impact. Posts recommendations directly on GitHub PRs.", main: "dist/index.cjs", bin: { @@ -2241,6 +2241,7 @@ function extractResourceInputs(planFilePath) { // engine.ts var HOURS_PER_MONTH = 730; var GRAMS_PER_KWH = 1e3; +var MEMORY_WATTS_PER_GB = 0.392; function resolveUtilization(input, ledger) { if (input.avgUtilization !== void 0 && (input.avgUtilization < 0 || input.avgUtilization > 1)) { throw new RangeError(`avgUtilization must be between 0 and 1, got ${input.avgUtilization}`); @@ -2250,8 +2251,10 @@ function resolveUtilization(input, ledger) { } return input.avgUtilization ?? ledger.metadata.assumptions.default_utilization.value; } -function linearInterpolationWatts(idle, max, utilization) { - return idle + (max - idle) * utilization; +function effectiveTotalWatts(idle, max, utilization, memoryGb) { + const cpuWatts = idle + (max - idle) * utilization; + const memoryWatts = memoryGb * MEMORY_WATTS_PER_GB; + return cpuWatts + memoryWatts; } function wattsToScope2Carbon(watts, hours, pue, gridIntensity) { return watts * pue * hours / GRAMS_PER_KWH * gridIntensity; @@ -2333,7 +2336,8 @@ function calculateBaseline(input, ledger = factors_default) { gridIntensityApplied: gridIntensity, powerModelUsed: "LINEAR_INTERPOLATION", embodiedCo2ePerVcpuPerMonthApplied: embodied, - waterIntensityLitresPerKwhApplied: waterIntensity + waterIntensityLitresPerKwhApplied: waterIntensity, + memoryWattsApplied: 0 } }); const regionData = providerLedger.regions[input.region]; @@ -2359,10 +2363,11 @@ function calculateBaseline(input, ledger = factors_default) { ); } const powerModel = "LINEAR_INTERPOLATION"; - const effectiveWatts = linearInterpolationWatts( + const effectiveWatts = effectiveTotalWatts( instanceData.power_watts.idle, instanceData.power_watts.max, - utilization + utilization, + instanceData.memory_gb ); const totalCo2eGramsPerMonth = wattsToScope2Carbon( effectiveWatts, @@ -2388,7 +2393,8 @@ function calculateBaseline(input, ledger = factors_default) { gridIntensityApplied: regionData.grid_intensity_gco2e_per_kwh, powerModelUsed: powerModel, embodiedCo2ePerVcpuPerMonthApplied: instanceData.embodied_co2e_grams_per_month, - waterIntensityLitresPerKwhApplied: regionData.water_intensity_litres_per_kwh + waterIntensityLitresPerKwhApplied: regionData.water_intensity_litres_per_kwh, + memoryWattsApplied: instanceData.memory_gb * MEMORY_WATTS_PER_GB } }; } diff --git a/engine.test.ts b/engine.test.ts index 04ed4e0..9f9b3a7 100644 --- a/engine.test.ts +++ b/engine.test.ts @@ -4,51 +4,53 @@ import { calculateBaseline } from './engine'; describe('calculateBaseline', () => { it('calculates the exact gCO2e value using the ledger default utilization (HIGH confidence)', () => { - // Audit Ledger Proof - // Instance: m5.large (x86_64, 2 vCPU, 8GB) - // Power: Idle = 6.8W, Max = 20.4W + // Audit Ledger Proof — v0.7.0 includes memory power draw (CCF standard: 0.392W/GB) + // Instance: m5.large (x86_64, 2 vCPU, 8GB RAM) + // Power: Idle=6.8W, Max=20.4W // Utilisation: 50% (0.5) [LEDGER DEFAULT] - // Effective Watts = 6.8 + (20.4 - 6.8) * 0.5 = 13.6W - // - // Region: us-east-1 - // Grid Intensity: 384.5 gCO2e/kWh - // PUE: 1.13 - // - // Power Draw = 13.6W * 1.13 = 15.368W - // Hours (default) = 730 - // Energy per month = 15.368W * 730 / 1000 = 11.21864 kWh - // Total Carbon = 11.21864 kWh * 384.5 gCO2e/kWh = 4313.56708 gCO2e - - const expectedCo2e = 4313.56708; - - // No avgUtilization supplied -> engine uses ledger default -> confidence HIGH + // + // CPU watts = 6.8 + (20.4 - 6.8) × 0.50 = 13.6W + // Memory = 8GB × 0.392W/GB = 3.136W + // Total = 13.6 + 3.136 = 16.736W + // + // Region: us-east-1 | Grid: 384.5 gCO2e/kWh | PUE: 1.13 + // + // Energy = 16.736W × 1.13 × 730h / 1000 = 13.82 kWh/month + // CO2e = 13.82 × 384.5 = 5,308.22g CO2e/month + + const expectedCo2e = 5308.2249; + const result = calculateBaseline({ - resourceId: 'test-db', + resourceId: 'test', region: 'us-east-1', instanceType: 'm5.large', }); assert.equal(result.confidence, 'HIGH'); - - // Assert exactly to 5 decimal places to prevent floating point mismatch assert.ok( - Math.abs(result.totalCo2eGramsPerMonth - expectedCo2e) < 0.0001, + Math.abs(result.totalCo2eGramsPerMonth - expectedCo2e) < 0.001, `Expected ~${expectedCo2e}, got ${result.totalCo2eGramsPerMonth}` ); + + // Verify memory watts are reported in assumptionsApplied + assert.ok( + Math.abs(result.assumptionsApplied.memoryWattsApplied - 3.136) < 0.001, + `Memory watts expected 3.136W, got ${result.assumptionsApplied.memoryWattsApplied}` + ); }); it('calculates a different gCO2e value for an explicitly-supplied utilization (MEDIUM confidence)', () => { const defaultResult = calculateBaseline({ - resourceId: 'test-db', + resourceId: 'test', region: 'us-east-1', instanceType: 'm5.large', }); const explicitResult = calculateBaseline({ - resourceId: 'test-db', + resourceId: 'test', region: 'us-east-1', instanceType: 'm5.large', - avgUtilization: 0.75, // Explicit value + avgUtilization: 0.75, }); assert.equal(explicitResult.confidence, 'MEDIUM'); @@ -57,24 +59,23 @@ describe('calculateBaseline', () => { it('calculates a meaningfully lower gCO2e value for the same instance in us-west-2', () => { const eastResult = calculateBaseline({ - resourceId: 'east-db', + resourceId: 'east', region: 'us-east-1', instanceType: 'm5.large', }); const westResult = calculateBaseline({ - resourceId: 'west-db', + resourceId: 'west', region: 'us-west-2', instanceType: 'm5.large', }); - // us-west-2 has grid intensity of 240.1, compared to us-east-1 which is 384.5 + // us-west-2 (240.1 gCO2e/kWh) vs us-east-1 (384.5 gCO2e/kWh) assert.ok( westResult.totalCo2eGramsPerMonth < eastResult.totalCo2eGramsPerMonth, - 'us-west-2 carbon should be lower than us-east-1' + 'us-west-2 should have lower carbon than us-east-1' ); - - // Verify it is meaningfully lower (>30% reduction) + // >30% reduction (ratio is ~0.624) assert.ok( westResult.totalCo2eGramsPerMonth < eastResult.totalCo2eGramsPerMonth * 0.7, 'us-west-2 should provide a significant carbon reduction' @@ -83,16 +84,16 @@ describe('calculateBaseline', () => { it('handles unsupported instance types gracefully', () => { const result = calculateBaseline({ - resourceId: 'unknown-db', + resourceId: 'unknown', region: 'us-east-1', instanceType: 'x99.superlarge', }); assert.equal(result.confidence, 'LOW_ASSUMED_DEFAULT'); assert.ok(result.unsupportedReason !== undefined); - assert.ok(result.unsupportedReason!.length > 0); assert.equal(result.totalCo2eGramsPerMonth, 0); assert.equal(result.totalCostUsdPerMonth, 0); + assert.equal(result.assumptionsApplied.memoryWattsApplied, 0); }); it('returns zero cost and carbon for unsupported region', () => { @@ -119,11 +120,7 @@ describe('calculateBaseline', () => { instanceType: 'm5.large', hoursPerMonth: 730, }); - // Both should produce identical carbon output - assert.equal( - withDefault.totalCo2eGramsPerMonth, - explicit730.totalCo2eGramsPerMonth - ); + assert.equal(withDefault.totalCo2eGramsPerMonth, explicit730.totalCo2eGramsPerMonth); }); it('throws RangeError for avgUtilization > 1', () => { @@ -150,11 +147,13 @@ describe('calculateBaseline', () => { }, RangeError); }); - it('accepts avgUtilization = 0 (idle-only carbon)', () => { + it('accepts avgUtilization = 0 (idle-only carbon + full memory)', () => { const result = calculateBaseline({ resourceId: 'test', region: 'us-east-1', instanceType: 'm5.large', avgUtilization: 0, }); - assert.ok(result.totalCo2eGramsPerMonth > 0, 'Should still have carbon from idle power'); + // At zero CPU utilization, memory power still contributes + assert.ok(result.totalCo2eGramsPerMonth > 0, 'Should have carbon from idle CPU + memory power'); + assert.ok(result.assumptionsApplied.memoryWattsApplied > 0, 'Memory watts should be non-zero'); }); it('accepts avgUtilization = 1 (max carbon)', () => { @@ -164,7 +163,7 @@ describe('calculateBaseline', () => { const max = calculateBaseline({ resourceId: 'test', region: 'us-east-1', instanceType: 'm5.large', avgUtilization: 1, }); - assert.ok(max.totalCo2eGramsPerMonth > idle.totalCo2eGramsPerMonth, 'Max utilization should produce more carbon'); + assert.ok(max.totalCo2eGramsPerMonth > idle.totalCo2eGramsPerMonth); }); it('returns scope SCOPE_2_AND_3 on all estimates', () => { @@ -174,19 +173,87 @@ describe('calculateBaseline', () => { assert.equal(result.scope, 'SCOPE_2_AND_3'); }); + // --------------------------------------------------------------------------- + // 4A: Memory power draw tests + // --------------------------------------------------------------------------- + + it('4A: memory power is included in Scope 2 calculation (CPU + memory watts)', () => { + // m5.large: CPU=13.6W at 50%, Memory=8GB×0.392=3.136W, Total=16.736W + // Without memory: 4313.57g (old). With memory: 5308.22g (new). + const result = calculateBaseline({ + resourceId: 'test', region: 'us-east-1', instanceType: 'm5.large', + }); + const expectedCo2e = 5308.2249; + const expectedMemW = 3.136; + + assert.ok( + Math.abs(result.totalCo2eGramsPerMonth - expectedCo2e) < 0.001, + `Expected ${expectedCo2e}, got ${result.totalCo2eGramsPerMonth}` + ); + assert.ok( + Math.abs(result.assumptionsApplied.memoryWattsApplied - expectedMemW) < 0.001, + `Expected memoryWatts=${expectedMemW}, got ${result.assumptionsApplied.memoryWattsApplied}` + ); + }); + + it('4A: memory-optimised instances carry higher memory power fraction than general-purpose', () => { + // r5.large: 2 vCPU, 16GB RAM — memory is 28.2% of total watts + // m5.large: 2 vCPU, 8GB RAM — memory is 18.7% of total watts + // r5.large should have meaningfully higher Scope 2 than m5.large + // even though both have similar CPU TDP profiles + const r5 = calculateBaseline({ + resourceId: 'test', region: 'us-east-1', instanceType: 'r5.large', + }); + const m5 = calculateBaseline({ + resourceId: 'test', region: 'us-east-1', instanceType: 'm5.large', + }); + + // r5.large memory=16GB vs m5.large memory=8GB + assert.ok( + r5.assumptionsApplied.memoryWattsApplied > m5.assumptionsApplied.memoryWattsApplied, + 'r5.large should have higher memory watts than m5.large' + ); + assert.ok( + Math.abs(r5.assumptionsApplied.memoryWattsApplied - 6.272) < 0.001, + `r5.large memory watts expected 6.272W, got ${r5.assumptionsApplied.memoryWattsApplied}` + ); + assert.ok(r5.totalCo2eGramsPerMonth > m5.totalCo2eGramsPerMonth, + 'r5.large should have higher total CO2e than m5.large' + ); + }); + + it('4A: memory power is constant regardless of CPU utilization', () => { + // Memory draws constant power — not affected by CPU utilisation + const at0 = calculateBaseline({ + resourceId: 'test', region: 'us-east-1', instanceType: 'm5.large', avgUtilization: 0, + }); + const at100 = calculateBaseline({ + resourceId: 'test', region: 'us-east-1', instanceType: 'm5.large', avgUtilization: 1, + }); + + // Memory watts should be identical regardless of utilization + assert.equal( + at0.assumptionsApplied.memoryWattsApplied, + at100.assumptionsApplied.memoryWattsApplied, + 'Memory watts should be constant across utilization levels' + ); + }); + // --------------------------------------------------------------------------- // Azure engine tests // --------------------------------------------------------------------------- it('Azure: calculates correct Scope 2 CO2e for Standard_D2s_v3 in eastus', () => { - // Audit trace: + // Audit trace (v0.7.0 — includes memory power): // Instance: Standard_D2s_v3 (x86_64, 2 vCPU, 8GB) - // Power: idle=6.8W, max=20.4W → effective at 50% = 13.6W + // CPU: idle=6.8W, max=20.4W → 13.6W at 50% + // Memory: 8GB × 0.392 = 3.136W + // Total: 16.736W // Region: eastus — grid=380.0 gCO2e/kWh, PUE=1.125, WUE=0.43 L/kWh - // Scope 2: 13.6 × 1.125 × 730 / 1000 × 380.0 = 4244.22 gCO2e/month - // Scope 3: 1041.7 gCO2e/month (2 vCPU × 520.83 g/vCPU/month, x86) - // Water: 13.6 × 730 / 1000 × 0.43 = 4.26904 L/month - // Cost: $0.096 × 730 = $70.08/month + // Scope 2: 16.736 × 1.125 × 730 / 1000 × 380.0 = 5,222.89 gCO2e/month + // Scope 3: 1041.7 gCO2e/month (unchanged) + // Water: 16.736 × 730 / 1000 × 0.43 = 5.253 L/month + // Cost: $0.096 × 730 = $70.08/month (unchanged) const result = calculateBaseline({ resourceId: 'azurerm_linux_virtual_machine.api', instanceType: 'Standard_D2s_v3', @@ -196,17 +263,15 @@ describe('calculateBaseline', () => { assert.equal(result.confidence, 'HIGH'); assert.equal(result.scope, 'SCOPE_2_AND_3'); - assert.ok(Math.abs(result.totalCo2eGramsPerMonth - 4244.22) < 0.01, `Scope 2 expected ~4244.22, got ${result.totalCo2eGramsPerMonth}`); - assert.ok(Math.abs(result.embodiedCo2eGramsPerMonth - 1041.7) < 0.01, 'Scope 3 should be 1041.7g'); - assert.ok(Math.abs(result.waterLitresPerMonth - 4.26904) < 0.001, `Water expected ~4.27L, got ${result.waterLitresPerMonth}`); - assert.ok(Math.abs(result.totalCostUsdPerMonth - 70.08) < 0.001, `Cost expected ~$70.08, got ${result.totalCostUsdPerMonth}`); + assert.ok(Math.abs(result.totalCo2eGramsPerMonth - 5222.8872) < 0.01, + `Scope 2 expected ~5222.89, got ${result.totalCo2eGramsPerMonth}`); + assert.ok(Math.abs(result.embodiedCo2eGramsPerMonth - 1041.7) < 0.01, 'Scope 3 unchanged'); + assert.ok(Math.abs(result.waterLitresPerMonth - 5.25343) < 0.001, + `Water expected ~5.25L, got ${result.waterLitresPerMonth}`); + assert.ok(Math.abs(result.totalCostUsdPerMonth - 70.08) < 0.001, 'Cost unchanged'); }); it('Azure: ARM upgrade recommendation (Standard_D2s_v3 → Standard_D2ps_v5) produces savings', () => { - // Standard_D2s_v3 (x86) → Standard_D2ps_v5 (ARM64/Ampere) - // ARM Scope 2: 2699.45 gCO2e/month — saves 1544.77g CO2e/month - // ARM Scope 3: 833.3g (2 vCPU ARM, 20% discount) — saves 208.4g embodied - // ARM Cost: $0.077 × 730 = $56.21 — saves $13.87/month const baseline = calculateBaseline({ resourceId: 'test', instanceType: 'Standard_D2s_v3', region: 'eastus', provider: 'azure', }); @@ -215,8 +280,8 @@ describe('calculateBaseline', () => { }); assert.ok(arm.totalCo2eGramsPerMonth < baseline.totalCo2eGramsPerMonth, 'ARM should have lower Scope 2'); - assert.ok(arm.embodiedCo2eGramsPerMonth < baseline.embodiedCo2eGramsPerMonth, 'ARM should have lower embodied carbon'); - assert.ok(arm.totalCostUsdPerMonth < baseline.totalCostUsdPerMonth, 'ARM should be cheaper'); + assert.ok(arm.embodiedCo2eGramsPerMonth < baseline.embodiedCo2eGramsPerMonth, 'ARM lower embodied'); + assert.ok(arm.totalCostUsdPerMonth < baseline.totalCostUsdPerMonth, 'ARM cheaper'); assert.ok(Math.abs(arm.embodiedCo2eGramsPerMonth - 833.3) < 0.01, 'ARM Scope 3 should be 833.3g'); }); @@ -241,14 +306,16 @@ describe('calculateBaseline', () => { // --------------------------------------------------------------------------- it('GCP: calculates correct Scope 2 CO2e for n2-standard-2 in us-central1', () => { - // Audit trace: + // Audit trace (v0.7.0 — includes memory power): // Instance: n2-standard-2 (x86_64, 2 vCPU, 8GB) - // Power: idle=6.8W, max=20.4W → effective at 50% = 13.6W + // CPU: idle=6.8W, max=20.4W → 13.6W at 50% + // Memory: 8GB × 0.392 = 3.136W + // Total: 16.736W // Region: us-central1 (Iowa) — grid=340.0 gCO2e/kWh, PUE=1.10, WUE=0.40 L/kWh - // Scope 2: 13.6 × 1.10 × 730 / 1000 × 340.0 = 3713.072 gCO2e/month - // Scope 3: 1041.7 gCO2e/month (2 vCPU x86) - // Water: 13.6 × 730 / 1000 × 0.40 = 3.9712 L/month - // Cost: $0.097 × 730 = $70.81/month + // Scope 2: 16.736 × 1.10 × 730 / 1000 × 340.0 = 4,569.26 gCO2e/month + // Scope 3: 1041.7 gCO2e/month (unchanged) + // Water: 16.736 × 730 / 1000 × 0.40 = 4.887 L/month + // Cost: $0.097 × 730 = $70.81/month (unchanged) const result = calculateBaseline({ resourceId: 'google_compute_instance.web', instanceType: 'n2-standard-2', @@ -258,17 +325,15 @@ describe('calculateBaseline', () => { assert.equal(result.confidence, 'HIGH'); assert.equal(result.scope, 'SCOPE_2_AND_3'); - assert.ok(Math.abs(result.totalCo2eGramsPerMonth - 3713.072) < 0.01, `Scope 2 expected ~3713.07, got ${result.totalCo2eGramsPerMonth}`); - assert.ok(Math.abs(result.embodiedCo2eGramsPerMonth - 1041.7) < 0.01, 'Scope 3 should be 1041.7g'); - assert.ok(Math.abs(result.waterLitresPerMonth - 3.9712) < 0.001, `Water expected ~3.97L, got ${result.waterLitresPerMonth}`); - assert.ok(Math.abs(result.totalCostUsdPerMonth - 70.81) < 0.001, `Cost expected ~$70.81, got ${result.totalCostUsdPerMonth}`); + assert.ok(Math.abs(result.totalCo2eGramsPerMonth - 4569.26272) < 0.01, + `Scope 2 expected ~4569.26, got ${result.totalCo2eGramsPerMonth}`); + assert.ok(Math.abs(result.embodiedCo2eGramsPerMonth - 1041.7) < 0.01, 'Scope 3 unchanged'); + assert.ok(Math.abs(result.waterLitresPerMonth - 4.88691) < 0.001, + `Water expected ~4.89L, got ${result.waterLitresPerMonth}`); + assert.ok(Math.abs(result.totalCostUsdPerMonth - 70.81) < 0.001, 'Cost unchanged'); }); it('GCP: ARM upgrade recommendation (n2-standard-2 → t2a-standard-2) produces savings', () => { - // n2-standard-2 (x86) → t2a-standard-2 (ARM64/Ampere T2A) - // ARM Scope 2: 2361.62 gCO2e/month — saves ~1351g CO2e/month - // ARM Scope 3: 833.3g (20% discount) — saves 208.4g embodied - // ARM Cost: $0.076 × 730 = $55.48 — saves ~$15.33/month const baseline = calculateBaseline({ resourceId: 'test', instanceType: 'n2-standard-2', region: 'us-central1', provider: 'gcp', }); @@ -276,25 +341,22 @@ describe('calculateBaseline', () => { resourceId: 'test', instanceType: 't2a-standard-2', region: 'us-central1', provider: 'gcp', }); - assert.ok(arm.totalCo2eGramsPerMonth < baseline.totalCo2eGramsPerMonth, 'ARM should have lower Scope 2'); - assert.ok(arm.embodiedCo2eGramsPerMonth < baseline.embodiedCo2eGramsPerMonth, 'ARM should have lower embodied carbon'); - assert.ok(arm.totalCostUsdPerMonth < baseline.totalCostUsdPerMonth, 'ARM should be cheaper'); + assert.ok(arm.totalCo2eGramsPerMonth < baseline.totalCo2eGramsPerMonth, 'ARM lower Scope 2'); + assert.ok(arm.embodiedCo2eGramsPerMonth < baseline.embodiedCo2eGramsPerMonth, 'ARM lower embodied'); + assert.ok(arm.totalCostUsdPerMonth < baseline.totalCostUsdPerMonth, 'ARM cheaper'); assert.ok(Math.abs(arm.embodiedCo2eGramsPerMonth - 833.3) < 0.01, 'ARM Scope 3 should be 833.3g'); }); it('GCP: GCP region has lower PUE than AWS (1.10 vs 1.13) — produces lower carbon than equivalent AWS', () => { - // Same instance power draw, same grid intensity (both ~380g), but GCP PUE=1.10 vs AWS PUE=1.13 - // AWS us-east-1: grid=384.5, PUE=1.13 - // GCP us-east1: grid=380.0, PUE=1.10 - // GCP should produce slightly less carbon per watt-hour const aws = calculateBaseline({ resourceId: 'test', instanceType: 'm5.large', region: 'us-east-1', provider: 'aws', }); const gcp = calculateBaseline({ resourceId: 'test', instanceType: 'n2-standard-2', region: 'us-east1', provider: 'gcp', }); - // Both are 2 vCPU x86 — similar power draw, similar grid. GCP PUE advantage should show. - assert.ok(gcp.totalCo2eGramsPerMonth < aws.totalCo2eGramsPerMonth, 'GCP us-east1 should have lower Scope 2 than AWS us-east-1 due to lower PUE'); + assert.ok(gcp.totalCo2eGramsPerMonth < aws.totalCo2eGramsPerMonth, + 'GCP us-east1 should have lower Scope 2 than AWS us-east-1 due to lower PUE' + ); }); it('GCP: returns LOW_ASSUMED_DEFAULT for unsupported instance', () => { diff --git a/engine.ts b/engine.ts index cd3cfde..7b46963 100644 --- a/engine.ts +++ b/engine.ts @@ -51,6 +51,13 @@ interface Ledger { const HOURS_PER_MONTH = 730; const GRAMS_PER_KWH = 1000; +/** + * Memory power draw coefficient — CCF standard (0.392W per GB of RAM). + * Applied to all instances with a known memory_gb value. + * Source: Cloud Carbon Footprint methodology v3. + */ +const MEMORY_WATTS_PER_GB = 0.392; + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -65,8 +72,14 @@ function resolveUtilization(input: ResourceInput, ledger: Ledger): number { return input.avgUtilization ?? ledger.metadata.assumptions.default_utilization.value; } -function linearInterpolationWatts(idle: number, max: number, utilization: number): number { - return idle + (max - idle) * utilization; +/** + * Returns total effective watts: CPU interpolation + memory power draw. + * Memory contribution is constant (not utilization-dependent) per CCF methodology. + */ +function effectiveTotalWatts(idle: number, max: number, utilization: number, memoryGb: number): number { + const cpuWatts = idle + (max - idle) * utilization; + const memoryWatts = memoryGb * MEMORY_WATTS_PER_GB; + return cpuWatts + memoryWatts; } function wattsToScope2Carbon(watts: number, hours: number, pue: number, gridIntensity: number): number { @@ -163,6 +176,7 @@ export function calculateBaseline( powerModelUsed: 'LINEAR_INTERPOLATION', embodiedCo2ePerVcpuPerMonthApplied: embodied, waterIntensityLitresPerKwhApplied: waterIntensity, + memoryWattsApplied: 0, }, }); @@ -190,8 +204,11 @@ export function calculateBaseline( } const powerModel: PowerModel = 'LINEAR_INTERPOLATION'; - const effectiveWatts = linearInterpolationWatts( - instanceData.power_watts.idle, instanceData.power_watts.max, utilization + const effectiveWatts = effectiveTotalWatts( + instanceData.power_watts.idle, + instanceData.power_watts.max, + utilization, + instanceData.memory_gb ); const totalCo2eGramsPerMonth = wattsToScope2Carbon( @@ -217,6 +234,7 @@ export function calculateBaseline( powerModelUsed: powerModel, embodiedCo2ePerVcpuPerMonthApplied: instanceData.embodied_co2e_grams_per_month, waterIntensityLitresPerKwhApplied: regionData.water_intensity_litres_per_kwh, + memoryWattsApplied: instanceData.memory_gb * MEMORY_WATTS_PER_GB, }, }; } diff --git a/formatters/markdown.test.ts b/formatters/markdown.test.ts index ff94e53..2ac5bfc 100644 --- a/formatters/markdown.test.ts +++ b/formatters/markdown.test.ts @@ -18,6 +18,7 @@ function makeMockBaseline(overrides: Record = {}) { powerModelUsed: 'LINEAR_INTERPOLATION' as const, embodiedCo2ePerVcpuPerMonthApplied: 833.3, waterIntensityLitresPerKwhApplied: 0.18, + memoryWattsApplied: 3.136, }, ...overrides, }; diff --git a/formatters/table.test.ts b/formatters/table.test.ts index 6020ca9..7d3ac68 100644 --- a/formatters/table.test.ts +++ b/formatters/table.test.ts @@ -18,6 +18,7 @@ function makeMockBaseline(overrides: Record = {}) { powerModelUsed: 'LINEAR_INTERPOLATION' as const, embodiedCo2ePerVcpuPerMonthApplied: 833.3, waterIntensityLitresPerKwhApplied: 0.18, + memoryWattsApplied: 3.136, }, ...overrides, }; diff --git a/integration.test.ts b/integration.test.ts index 80571fd..9b3954a 100644 --- a/integration.test.ts +++ b/integration.test.ts @@ -61,52 +61,49 @@ describe('End-to-End Integration', () => { const result = analysePlan(resources, skipped, tmpFile); - // --- Math traces from factors.json v1.2.0 --- + // --- Math traces from factors.json v1.2.0 — v0.7.0 includes memory power (0.392W/GB) --- // - // Baseline calculations (watts = idle + (max-idle)*0.5, pue applied, 730h/month): + // W_effective = W_cpu + W_memory + // W_cpu = W_idle + (W_max - W_idle) × 0.5 + // W_memory = memory_gb × 0.392W/GB // - // 1. aws_instance.web — m5.large us-east-1 - // watts = 6.8 + (20.4-6.8)*0.5 = 13.6W - // energy = 13.6 * 1.13 * 730 / 1000 = 11.226kWh - // co2e = 11.226 * 384.5 = 4313.567g - // cost = 0.0960 * 730 = $70.08 + // 1. aws_instance.web — m5.large us-east-1 (8GB) + // W_cpu=13.6W, W_mem=3.136W, W_total=16.736W + // energy = 16.736 × 1.13 × 730 / 1000 = 13.816 kWh + // co2e = 13.816 × 384.5 = 5308.22g + // cost = $0.096 × 730 = $70.08 // - // 2. aws_instance.worker — m6g.large us-west-2 - // watts = 4.1 + (13.2-4.1)*0.5 = 8.65W - // energy = 8.65 * 1.13 * 730 / 1000 = 7.138kWh - // co2e = 7.138 * 240.1 = 1713.206g - // cost = 0.0770 * 730 = $56.21 + // 2. aws_instance.worker — m6g.large us-west-2 (ARM, 8GB) + // W_cpu=8.65W, W_mem=3.136W, W_total=11.786W + // energy = 11.786 × 1.13 × 730 / 1000 = 9.722 kWh + // co2e = 9.722 × 240.1 = 2334.32g + // cost = $0.077 × 730 = $56.21 // - // 3. aws_db_instance.db — m5.xlarge eu-west-1 (normalised from db.m5.xlarge) - // watts = 13.6 + (40.8-13.6)*0.5 = 27.2W - // energy = 27.2 * 1.13 * 730 / 1000 = 22.451kWh - // co2e = 22.451 * 334.0 = 7494.052g - // cost = 0.1070 * 730 = $78.11... wait actual is 0.2140*730=$156.22 (xlarge not large) - // — confirmed: 0.2140 * 730 = $156.22 + // 3. aws_db_instance.db — m5.xlarge eu-west-1 (normalised from db.m5.xlarge, 16GB) + // W_cpu=27.2W, W_mem=6.272W, W_total=33.472W + // energy = 33.472 × 1.13 × 730 / 1000 = 27.622 kWh + // co2e = 27.622 × 334.0 = 9222.09g + // cost = $0.214 × 730 = $156.22 // - // Total baseline: 4313.567 + 1713.206 + 7494.052 = 13520.825g, $282.51 - // - // Recommendation savings: - // web: eu-north-1 shift → saves 4214.843g, costs +$2.92/mo - // worker: eu-north-1 shift → saves 1650.415g, costs $0.00/mo - // db: eu-north-1 shift → saves 7296.603g, saves $10.22/mo - // - // Total savings: 4214.843 + 1650.415 + 7296.603 = 13161.861g - // Total cost savings: |2.92| + |0.00| + |10.22| = 13.14 (net of cost increases) - // Note: potentialCostSavingUsdPerMonth uses Math.abs() of each delta, - // so cost increases count the same as cost decreases in the total. + // Total: 5308.22 + 2334.32 + 9222.09 = 16864.63g, $282.51 + // Note: potentialCostSavingUsdPerMonth uses Math.abs() of each delta. // ----------------------------------------------- - const totalCo2e = 4313.567079999999 + 1713.2059385 + 7494.05152; + // v0.7.0: Memory power draw included (0.392W/GB) + // m5.large us-east-1: cpu=13.6W + mem=3.136W = 16.736W → 5308.22g CO2e + // m6g.large us-west-2: ARM — cpu=8.65W + mem=3.136W = 11.786W → 2334.32g CO2e + // m5.xlarge eu-west-1: cpu=27.2W + mem=6.272W = 33.472W → 9222.09g CO2e + const totalCo2e = 5308.2249 + 2334.31736 + 9222.09164; const totalCost = 70.08 + 56.21 + 156.22; - assert.ok(Math.abs(result.totals.currentCo2eGramsPerMonth - totalCo2e) < 0.001); + assert.ok(Math.abs(result.totals.currentCo2eGramsPerMonth - totalCo2e) < 0.01); assert.ok(Math.abs(result.totals.currentCostUsdPerMonth - totalCost) < 0.001); // All three resources now have recommendations (eu-north-1 shift) - // Verify savings are substantial — >90% of total baseline CO2e + // Verify savings are substantial — >85% of total baseline CO2e + // (memory power increases baseline, slightly reducing the savings percentage) const savingsPct = result.totals.potentialCo2eSavingGramsPerMonth / result.totals.currentCo2eGramsPerMonth; - assert.ok(savingsPct > 0.90, `Expected >90% CO2e savings with 14-region ledger, got ${(savingsPct*100).toFixed(1)}%`); + assert.ok(savingsPct > 0.85, `Expected >85% CO2e savings with 14-region ledger, got ${(savingsPct*100).toFixed(1)}%`); // All three resources should have a recommendation const resourcesWithRecs = result.resources.filter(r => r.recommendation !== null); diff --git a/package.json b/package.json index a99603b..b14cddf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "greenops-cli", - "version": "0.6.0", + "version": "0.7.0", "description": "Carbon footprint linting for Terraform plans \u2014 AWS, Azure, and GCP. Analyses infrastructure changes for Scope 2, Scope 3, and water impact. Posts recommendations directly on GitHub PRs.", "main": "dist/index.cjs", "bin": { diff --git a/types.ts b/types.ts index 9f6ef2f..bed2588 100644 --- a/types.ts +++ b/types.ts @@ -74,6 +74,8 @@ export interface EmissionAndCostEstimate { powerModelUsed: PowerModel; embodiedCo2ePerVcpuPerMonthApplied: number; waterIntensityLitresPerKwhApplied: number; + /** Memory power draw applied (W) = memory_gb × 0.392W/GB. CCF standard. */ + memoryWattsApplied: number; }; }